import { describe, test, expect, beforeAll, afterAll } from 'bun:test'; import { chromium } from 'playwright'; import type { Browser, BrowserContext } from 'playwright'; import { $ } from 'bun'; import { existsSync, readFileSync } from 'node:fs'; import { BASE, DATA_DIR, ADMIN_PASS, setupTestEnv, spawnServer, waitForServer, login, logout, seedRepo, seedBranch, seedSubdir, writeTempFile, getHeadCommit, PORT, } from './helpers.ts'; // ─── Global setup ──────────────────────────────────────────────────────────── let browser: Browser; let server: ReturnType; beforeAll(async () => { await setupTestEnv(); server = spawnServer(); await waitForServer(); browser = await chromium.launch(); }); afterAll(async () => { await browser.close(); server.kill(); }); // Helper: create a context already logged in as a given user. async function loggedInContext(username = 'admin', password = ADMIN_PASS) { const ctx = await browser.newContext(); const page = await ctx.newPage(); await login(page, username, password); await page.close(); return ctx; } // Helper: create N issues in a repo using the server API (no browser rendering) async function bulkCreateIssues(ctx: BrowserContext, repo: string, count: number) { for (let i = 1; i <= count; i++) { await ctx.request.fetch(`${BASE}/${repo}/issues`, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, data: `title=Issue+number+${i}&body=`, maxRedirects: 0, }).catch(() => {}); // 302 redirect throws; that's fine } } // Helper: create N patches in a repo using the server API async function bulkCreatePatches(ctx: BrowserContext, repo: string, count: number) { const VALID_PATCH = [ 'From a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2 Mon Sep 17 00:00:00 2001', 'From: Test User ', 'Date: Mon, 01 Jan 2024 12:00:00 +0000', 'Subject: [PATCH] Add f.txt', '', '---', 'diff --git a/f.txt b/f.txt', 'new file mode 100644', 'index 0000000..9daeafb', '--- /dev/null', '+++ b/f.txt', '@@ -0,0 +1 @@', '+x', '', ].join('\n'); writeTempFile('/tmp/bulk.patch', VALID_PATCH); for (let i = 1; i <= count; i++) { const page = await ctx.newPage(); try { await page.goto(`${BASE}/${repo}/patches/new`); await page.fill('[name=title]', `Patch number ${i}`); await page.locator('[name=patch_file]').setInputFiles('/tmp/bulk.patch'); await page.click('form[action$="/patches"] button[type=submit]'); await page.waitForURL(new RegExp(`/${repo}/patches/\\d+`)); } finally { await page.close(); } } } // ─── Auth ───────────────────────────────────────────────────────────────────── describe('auth', () => { test('homepage loads', async () => { const ctx = await browser.newContext(); const page = await ctx.newPage(); try { await page.goto(BASE); expect(await page.title()).toContain('Hearthforge'); } finally { await ctx.close(); } }); test('wrong password shows error', async () => { const ctx = await browser.newContext(); const page = await ctx.newPage(); try { await page.goto(`${BASE}/login`); await page.fill('[name=username]', 'admin'); await page.fill('[name=password]', 'wrongpassword'); await page.click('button[type=submit]'); expect(await page.locator('.form-error').textContent()).toContain('Invalid'); } finally { await ctx.close(); } }); test('correct credentials redirect to homepage', async () => { const ctx = await browser.newContext(); const page = await ctx.newPage(); try { await login(page); expect(page.url()).toBe(BASE + '/'); expect(await page.locator('.nav-user').isVisible()).toBe(true); } finally { await ctx.close(); } }); test('register new user', async () => { const ctx = await browser.newContext(); const page = await ctx.newPage(); try { await page.goto(`${BASE}/register`); await page.fill('[name=username]', 'alice'); await page.fill('[name=password]', 'password123'); await page.fill('[name=password2]', 'password123'); await page.click('button[type=submit]'); await page.waitForURL(BASE + '/'); expect(await page.locator('.nav-user').textContent()).toBe('alice'); } finally { await ctx.close(); } }); test('register with mismatched passwords shows error', async () => { const ctx = await browser.newContext(); const page = await ctx.newPage(); try { await page.goto(`${BASE}/register`); await page.fill('[name=username]', 'bob'); await page.fill('[name=password]', 'password123'); await page.fill('[name=password2]', 'different456'); await page.click('button[type=submit]'); expect(await page.locator('.form-error').textContent()).toContain('match'); } finally { await ctx.close(); } }); test('register with duplicate username shows error', async () => { const ctx = await browser.newContext(); const page = await ctx.newPage(); try { await page.goto(`${BASE}/register`); await page.fill('[name=username]', 'alice'); // already registered above await page.fill('[name=password]', 'password123'); await page.fill('[name=password2]', 'password123'); await page.click('button[type=submit]'); expect(await page.locator('.form-error').textContent()).toContain('taken'); } finally { await ctx.close(); } }); test('logout clears session', async () => { const ctx = await browser.newContext(); const page = await ctx.newPage(); try { await login(page); await logout(page); expect(await page.locator('.nav-user').count()).toBe(0); expect(await page.locator('a[href="/login"]').isVisible()).toBe(true); } finally { await ctx.close(); } }); }); // ─── Repos ──────────────────────────────────────────────────────────────────── describe('repos', () => { // Shared admin context — cookies persist across tests in this block. let adminCtx: BrowserContext; // Alice's context, created after alice is registered in auth tests. let aliceCtx: BrowserContext; // Set after 'commit log' test; used by all commit-detail tests below. let commitUrl: string; beforeAll(async () => { adminCtx = await loggedInContext(); aliceCtx = await loggedInContext('alice', 'password123'); }); afterAll(async () => { await adminCtx.close(); await aliceCtx.close(); }); test('non-admin gets 403 on /new', async () => { const page = await aliceCtx.newPage(); try { const resp = await page.request.get(`${BASE}/new`); expect(resp.status()).toBe(403); } finally { await page.close(); } }); test('create repository', async () => { const page = await adminCtx.newPage(); try { await page.goto(`${BASE}/new`); await page.fill('[name=name]', 'my-repo'); await page.fill('[name=description]', 'A test repo'); await page.click('form[action="/new"] button[type=submit]'); await page.waitForURL(`${BASE}/my-repo`); expect(await page.locator('.empty-state').isVisible()).toBe(true); } finally { await page.close(); } }); test('repository appears in list', async () => { const page = await adminCtx.newPage(); try { await page.goto(BASE); expect(await page.locator('.repo-name').allTextContents()).toContain('my-repo'); } finally { await page.close(); } }); test('search finds matching repo', async () => { const page = await adminCtx.newPage(); try { await page.goto(BASE); await page.fill('[name=q]', 'my-repo'); await page.click('.search-form button[type=submit]'); expect(await page.locator('.repo-name').allTextContents()).toContain('my-repo'); } finally { await page.close(); } }); test('search returns empty for unknown term', async () => { const page = await adminCtx.newPage(); try { await page.goto(BASE); await page.fill('[name=q]', 'zzz-nothing-here'); await page.click('.search-form button[type=submit]'); expect(await page.locator('.empty-state').isVisible()).toBe(true); } finally { await page.close(); } }); test('browse file tree after seeding content', async () => { await seedRepo('my-repo'); const page = await adminCtx.newPage(); try { await page.goto(`${BASE}/my-repo/tree/main`); const files = await page.locator('.file-name a').allTextContents(); expect(files).toContain('README.md'); expect(files).toContain('index.js'); } finally { await page.close(); } }); test('view file blob with syntax highlighting', async () => { const page = await adminCtx.newPage(); try { await page.goto(`${BASE}/my-repo/blob/main/index.js`); expect(await page.locator('.file-blob-name').textContent()).toBe('index.js'); expect(await page.locator('.file-blob-body').isVisible()).toBe(true); } finally { await page.close(); } }); test('raw file download responds 200', async () => { const page = await adminCtx.newPage(); try { const resp = await page.request.get(`${BASE}/my-repo/raw/main/README.md`); expect(resp.status()).toBe(200); expect(resp.headers()['content-disposition']).toContain('README.md'); } finally { await page.close(); } }); test('commit log shows initial commit', async () => { const page = await adminCtx.newPage(); try { await page.goto(`${BASE}/my-repo/commits/main`); const subjects = await page.locator('.commit-subject').allTextContents(); expect(subjects.some(s => s.includes('Initial commit'))).toBe(true); // Navigate to the commit page and capture the URL for subsequent tests await page.locator('.commit-hash').first().click(); await page.waitForURL(/\/my-repo\/commit\//); commitUrl = page.url(); } finally { await page.close(); } }); test('commit detail shows metadata card', async () => { const page = await adminCtx.newPage(); try { await page.goto(commitUrl); expect(await page.locator('.commit-card').isVisible()).toBe(true); expect(await page.locator('.commit-card-subject').textContent()).toContain('Initial commit'); // Author, date and SHA rows are all present const metaText = await page.locator('.commit-card-meta').textContent(); expect(metaText).toContain('Test'); // author name set by seedRepo expect(metaText).toContain('Author'); // label (CSS uppercases visually) expect(metaText).toContain('Date'); expect(metaText).toContain('Commit'); } finally { await page.close(); } }); test('commit detail full SHA is shown', async () => { const page = await adminCtx.newPage(); try { await page.goto(commitUrl); const sha = commitUrl.split('/commit/')[1] ?? null; expect(await page.locator('.commit-sha-full').textContent()).toBe(sha); } finally { await page.close(); } }); test('commit detail shows file nav sidebar', async () => { const page = await adminCtx.newPage(); try { await page.goto(commitUrl); expect(await page.locator('.file-nav-details').isVisible()).toBe(true); const navItems = await page.locator('.file-nav-item').allTextContents(); // seedRepo adds README.md and index.js expect(navItems.some(t => t.includes('README.md'))).toBe(true); expect(navItems.some(t => t.includes('index.js'))).toBe(true); } finally { await page.close(); } }); test('commit detail file nav items are anchor links to diff sections', async () => { const page = await adminCtx.newPage(); try { await page.goto(commitUrl); const hrefs = await page.locator('.file-nav-item').evaluateAll( els => els.map(el => el.getAttribute('href') ?? ''), ); expect(hrefs.every(h => h.startsWith('#'))).toBe(true); } finally { await page.close(); } }); test('commit detail shows diff table with added lines', async () => { const page = await adminCtx.newPage(); try { await page.goto(commitUrl); // Initial commit only adds lines expect(await page.locator('.diff-table').first().isVisible()).toBe(true); expect(await page.locator('.diff-row-add').count()).toBeGreaterThan(0); expect(await page.locator('.diff-row-del').count()).toBe(0); } finally { await page.close(); } }); test('commit detail diff table has line numbers', async () => { const page = await adminCtx.newPage(); try { await page.goto(commitUrl); // New-side line numbers (column 2) on add rows start at 1 const firstNewLn = await page.locator('.diff-row-add .diff-ln-new').first().textContent(); expect(firstNewLn?.trim()).toBe('1'); } finally { await page.close(); } }); test('commit detail shows added stats on file header', async () => { const page = await adminCtx.newPage(); try { await page.goto(commitUrl); const addStats = await page.locator('.diff-stat-add').allTextContents(); expect(addStats.length).toBeGreaterThan(0); expect(addStats.every(s => s.startsWith('+'))).toBe(true); } finally { await page.close(); } }); test('commit detail view-at-sha button links to blob at that commit', async () => { const page = await adminCtx.newPage(); try { await page.goto(commitUrl); const sha = commitUrl.split('/commit/')[1]; const btn = page.locator('.btn-xs').first(); const href = await btn.getAttribute('href'); expect(href).toContain(`/blob/${sha}/`); } finally { await page.close(); } }); test('commit detail view-at-branch button links to blob at default branch', async () => { const page = await adminCtx.newPage(); try { await page.goto(commitUrl); const btns = await page.locator('.btn-xs').allTextContents(); expect(btns.some(t => t.includes('@ main'))).toBe(true); const branchBtns = await page.locator('.btn-xs').evaluateAll( els => els.filter(el => el.textContent?.includes('@ main')).map(el => el.getAttribute('href') ?? ''), ); expect(branchBtns.every(h => h.includes('/blob/main/'))).toBe(true); } finally { await page.close(); } }); test('commit detail file diff can be collapsed', async () => { const page = await adminCtx.newPage(); try { await page.goto(commitUrl); // File body is visible when details is open const diffFile = page.locator('.diff-file').first(); expect(await diffFile.getAttribute('open')).not.toBeNull(); // Click the summary to collapse await diffFile.locator('.diff-file-header').click(); expect(await diffFile.getAttribute('open')).toBeNull(); } finally { await page.close(); } }); test('commit detail file nav sidebar can be collapsed', async () => { const page = await adminCtx.newPage(); try { await page.goto(commitUrl); const nav = page.locator('.file-nav-details'); expect(await nav.getAttribute('open')).not.toBeNull(); await nav.locator('.file-nav-toggle').click(); expect(await nav.getAttribute('open')).toBeNull(); } finally { await page.close(); } }); test('readme renders on repo home', async () => { const page = await adminCtx.newPage(); try { await page.goto(`${BASE}/my-repo`); expect(await page.locator('.readme-header').isVisible()).toBe(true); expect(await page.locator('.readme-section .markdown-body').innerHTML()).toContain('my-repo'); } finally { await page.close(); } }); test('private repo hidden from other users', async () => { // Make private const adminPage = await adminCtx.newPage(); try { await adminPage.goto(`${BASE}/my-repo/settings`); await adminPage.check('[name=is_private]'); await adminPage.click('form[action$="/settings"] button[type=submit]'); expect(await adminPage.locator('.form-success').isVisible()).toBe(true); } finally { await adminPage.close(); } // Alice should get 404 const alicePage = await aliceCtx.newPage(); try { const resp = await alicePage.request.get(`${BASE}/my-repo`); expect(resp.status()).toBe(404); await alicePage.goto(BASE); expect(await alicePage.locator('.repo-name').allTextContents()).not.toContain('my-repo'); } finally { await alicePage.close(); } // Restore to public const adminPage2 = await adminCtx.newPage(); try { await adminPage2.goto(`${BASE}/my-repo/settings`); await adminPage2.uncheck('[name=is_private]'); await adminPage2.click('form[action$="/settings"] button[type=submit]'); } finally { await adminPage2.close(); } }); test('settings tab visible for admin, hidden for others', async () => { const adminPage = await adminCtx.newPage(); try { await adminPage.goto(`${BASE}/my-repo`); expect(await adminPage.locator('.repo-tab[href$="/settings"]').isVisible()).toBe(true); } finally { await adminPage.close(); } const alicePage = await aliceCtx.newPage(); try { await alicePage.goto(`${BASE}/my-repo`); expect(await alicePage.locator('.repo-tab[href$="/settings"]').count()).toBe(0); } finally { await alicePage.close(); } }); test('create repository with invalid name shows error', async () => { const resp = await adminCtx.request.post(`${BASE}/new`, { form: { name: 'has spaces!', description: '', default_branch: 'main' }, maxRedirects: 0, }); expect(resp.status()).toBe(200); expect(await resp.text()).toContain('Invalid repository name'); }); }); // ─── Issues ─────────────────────────────────────────────────────────────────── describe('issues', () => { let adminCtx: BrowserContext; let issueUrl: string; let completedIssueUrl: string; beforeAll(async () => { adminCtx = await loggedInContext(); }); afterAll(async () => { await adminCtx.close(); }); test('create issue', async () => { const page = await adminCtx.newPage(); try { await page.goto(`${BASE}/my-repo/issues/new`); await page.fill('[name=title]', 'First issue'); await page.fill('[name=body]', 'Body with **markdown**.'); await page.click('form[action$="/issues"] button[type=submit]'); await page.waitForURL(/\/my-repo\/issues\/\d+/); issueUrl = page.url(); expect(await page.locator('.issue-detail-title').textContent()).toBe('First issue'); } finally { await page.close(); } }); test('issue body renders markdown', async () => { const page = await adminCtx.newPage(); try { await page.goto(issueUrl); expect(await page.locator('.timeline-body.markdown-body').first().innerHTML()).toContain(''); } finally { await page.close(); } }); test('issue appears in open list', async () => { const page = await adminCtx.newPage(); try { await page.goto(`${BASE}/my-repo/issues`); const titles = await page.locator('.issue-title').allTextContents(); expect(titles.some(t => t.includes('First issue'))).toBe(true); } finally { await page.close(); } }); test('unauthenticated user is redirected to login from new issue form', async () => { const ctx = await browser.newContext(); const page = await ctx.newPage(); try { await page.goto(`${BASE}/my-repo/issues/new`); expect(page.url()).toContain('/login'); } finally { await ctx.close(); } }); test('add comment', async () => { const page = await adminCtx.newPage(); try { await page.goto(issueUrl); const beforeCount = await page.locator('.timeline-item').count(); await page.fill('textarea[name=body]', 'A follow-up comment.'); await page.click('form[action*="/comments"] button[type=submit]'); await page.waitForURL(new RegExp(issueUrl.replace(BASE, ''))); expect(await page.locator('.timeline-item').count()).toBeGreaterThan(beforeCount); } finally { await page.close(); } }); test('react to issue', async () => { const page = await adminCtx.newPage(); try { await page.goto(issueUrl); await page.locator('.reaction-picker').first().click(); await page.locator('.reaction-picker-btn').first().click(); await page.waitForURL(new RegExp(issueUrl.replace(BASE, ''))); expect(await page.locator('.reaction-btn').count()).toBeGreaterThan(0); } finally { await page.close(); } }); test('close issue changes status badge', async () => { const page = await adminCtx.newPage(); try { await page.goto(issueUrl); await page.click('form[action*="/close"] button'); await page.waitForURL(new RegExp(issueUrl.replace(BASE, ''))); expect(await page.locator('.issue-badge').textContent()).toBe('closed'); } finally { await page.close(); } }); test('closed issue appears in closed list', async () => { const page = await adminCtx.newPage(); try { await page.goto(`${BASE}/my-repo/issues?status=closed`); const titles = await page.locator('.issue-title').allTextContents(); expect(titles.some(t => t.includes('First issue'))).toBe(true); } finally { await page.close(); } }); test('reopen issue', async () => { const page = await adminCtx.newPage(); try { await page.goto(issueUrl); await page.click('.issue-detail-meta-actions button'); await page.waitForURL(new RegExp(issueUrl.replace(BASE, ''))); expect(await page.locator('.issue-badge').textContent()).toBe('open'); } finally { await page.close(); } }); test('completed button marks issue as completed', async () => { const page = await adminCtx.newPage(); try { await page.goto(`${BASE}/my-repo/issues/new`); await page.fill('[name=title]', 'To be completed'); await page.click('form[action$="/issues"] button[type=submit]'); await page.waitForURL(/\/my-repo\/issues\/\d+/); completedIssueUrl = page.url(); await page.click('form[action*="/complete"] button'); await page.waitForURL(new RegExp(completedIssueUrl.replace(BASE, ''))); expect(await page.locator('.issue-badge').textContent()).toBe('completed'); } finally { await page.close(); } }); test('completed issue appears in completed list', async () => { const page = await adminCtx.newPage(); try { await page.goto(`${BASE}/my-repo/issues?status=completed`); const titles = await page.locator('.issue-title').allTextContents(); expect(titles.some(t => t.includes('To be completed'))).toBe(true); } finally { await page.close(); } }); test('non-admin cannot complete or close issue', async () => { const issueNum = issueUrl.split('/issues/')[1]; const ctx = await browser.newContext(); try { const completeResp = await ctx.request.post( `${BASE}/my-repo/issues/${issueNum}/complete`, { maxRedirects: 0 }, ); expect(completeResp.status()).toBe(302); expect(completeResp.headers()['location']).toContain('/login'); } finally { await ctx.close(); } }); test('reacting with same emoji toggles it off', async () => { const page = await adminCtx.newPage(); try { await page.goto(issueUrl); // Reaction was added by the earlier 'react to issue' test expect(await page.locator('.reaction-btn').count()).toBeGreaterThan(0); await page.locator('.reaction-btn').first().click(); await page.waitForURL(new RegExp(issueUrl.replace(BASE, ''))); expect(await page.locator('.reaction-btn').count()).toBe(0); } finally { await page.close(); } }); test('react to issue comment', async () => { const page = await adminCtx.newPage(); try { await page.goto(issueUrl); const commentItem = page.locator('.timeline-item:not(.timeline-item-new)') .filter({ hasText: 'A follow-up comment.' }); await commentItem.locator('.reaction-add-btn').click(); await commentItem.locator('.reaction-picker-btn').first().click(); await page.waitForURL(new RegExp(issueUrl.replace(BASE, ''))); expect(await commentItem.locator('.reaction-btn').count()).toBeGreaterThan(0); } finally { await page.close(); } }); }); // ─── Patches ────────────────────────────────────────────────────────────────── describe('patches', () => { let adminCtx: BrowserContext; let cleanPatchUrl: string; let conflictPatchUrl: string; let closePatchUrl: string; // Adds a new file — applies cleanly to my-repo const CLEAN_PATCH = [ 'From a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2 Mon Sep 17 00:00:00 2001', 'From: Test User ', 'Date: Mon, 01 Jan 2024 12:00:00 +0000', 'Subject: [PATCH] Add patch-test.txt', '', '---', 'diff --git a/patch-test.txt b/patch-test.txt', 'new file mode 100644', 'index 0000000..9daeafb', '--- /dev/null', '+++ b/patch-test.txt', '@@ -0,0 +1 @@', '+patch test content', '', ].join('\n'); // References non-existent lines in README.md — always conflicts const CONFLICT_PATCH = [ 'From a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b3 Mon Sep 17 00:00:00 2001', 'From: Test User ', 'Date: Mon, 01 Jan 2024 12:00:00 +0000', 'Subject: [PATCH] Modify README', '', '---', 'diff --git a/README.md b/README.md', 'index abc1234..def5678 100644', '--- a/README.md', '+++ b/README.md', '@@ -50,3 +50,3 @@', ' nonexistent context line', '-nonexistent old line', '+nonexistent new line', '', ].join('\n'); // Adds another new file — for testing close flow const CLOSE_PATCH = [ 'From a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b4 Mon Sep 17 00:00:00 2001', 'From: Test User ', 'Date: Mon, 01 Jan 2024 12:00:00 +0000', 'Subject: [PATCH] Add patch-close.txt', '', '---', 'diff --git a/patch-close.txt b/patch-close.txt', 'new file mode 100644', 'index 0000000..9daeafb', '--- /dev/null', '+++ b/patch-close.txt', '@@ -0,0 +1 @@', '+close test', '', ].join('\n'); beforeAll(async () => { adminCtx = await loggedInContext(); }); afterAll(async () => { await adminCtx.close(); }); test('reject file without patch markers', async () => { writeTempFile('/tmp/not-a-patch.txt', 'this is just plain text'); const page = await adminCtx.newPage(); try { await page.goto(`${BASE}/my-repo/patches/new`); await page.fill('[name=title]', 'Bad patch'); await page.locator('[name=patch_file]').setInputFiles('/tmp/not-a-patch.txt'); await page.click('form[action$="/patches"] button[type=submit]'); expect(await page.locator('.form-error').textContent()).toContain('valid patch'); } finally { await page.close(); } }); test('reject patch missing Subject header', async () => { writeTempFile('/tmp/no-subject.patch', [ 'From: Test User ', 'Date: Mon, 01 Jan 2024 12:00:00 +0000', '', '---', 'diff --git a/f.txt b/f.txt', 'new file mode 100644', '--- /dev/null', '+++ b/f.txt', '@@ -0,0 +1 @@', '+x', '', ].join('\n')); const page = await adminCtx.newPage(); try { await page.goto(`${BASE}/my-repo/patches/new`); await page.fill('[name=title]', 'No subject'); await page.locator('[name=patch_file]').setInputFiles('/tmp/no-subject.patch'); await page.click('form[action$="/patches"] button[type=submit]'); expect(await page.locator('.form-error').textContent()).toContain('Subject'); } finally { await page.close(); } }); test('reject patch missing From header', async () => { writeTempFile('/tmp/no-from.patch', [ 'Date: Mon, 01 Jan 2024 12:00:00 +0000', 'Subject: [PATCH] Add f.txt', '', '---', 'diff --git a/f.txt b/f.txt', 'new file mode 100644', '--- /dev/null', '+++ b/f.txt', '@@ -0,0 +1 @@', '+x', '', ].join('\n')); const page = await adminCtx.newPage(); try { await page.goto(`${BASE}/my-repo/patches/new`); await page.fill('[name=title]', 'No from'); await page.locator('[name=patch_file]').setInputFiles('/tmp/no-from.patch'); await page.click('form[action$="/patches"] button[type=submit]'); expect(await page.locator('.form-error').textContent()).toContain('From'); } finally { await page.close(); } }); test('reject patch missing Date header', async () => { writeTempFile('/tmp/no-date.patch', [ 'From: Test User ', 'Subject: [PATCH] Add f.txt', '', '---', 'diff --git a/f.txt b/f.txt', 'new file mode 100644', '--- /dev/null', '+++ b/f.txt', '@@ -0,0 +1 @@', '+x', '', ].join('\n')); const page = await adminCtx.newPage(); try { await page.goto(`${BASE}/my-repo/patches/new`); await page.fill('[name=title]', 'No date'); await page.locator('[name=patch_file]').setInputFiles('/tmp/no-date.patch'); await page.click('form[action$="/patches"] button[type=submit]'); expect(await page.locator('.form-error').textContent()).toContain('Date'); } finally { await page.close(); } }); test('upload clean patch', async () => { writeTempFile('/tmp/clean.patch', CLEAN_PATCH); const page = await adminCtx.newPage(); try { await page.goto(`${BASE}/my-repo/patches/new`); await page.fill('[name=title]', 'Add patch-test.txt'); await page.fill('[name=description]', 'Adds a file with **markdown** desc.'); await page.locator('[name=patch_file]').setInputFiles('/tmp/clean.patch'); await page.click('form[action$="/patches"] button[type=submit]'); await page.waitForURL(/\/my-repo\/patches\/\d+/); cleanPatchUrl = page.url(); expect(await page.locator('.issue-detail-title').textContent()).toBe('Add patch-test.txt'); } finally { await page.close(); } }); test('author on patch comes from patch From header', async () => { const page = await adminCtx.newPage(); try { await page.goto(cleanPatchUrl); expect(await page.locator('.patch-author-identity').textContent()).toContain('Test User'); expect(await page.locator('.patch-author-identity').textContent()).toContain('test@example.com'); } finally { await page.close(); } }); test('changes tab shows commit metadata card', async () => { const page = await adminCtx.newPage(); try { await page.goto(cleanPatchUrl + '?tab=changes'); expect(await page.locator('.commit-card').isVisible()).toBe(true); expect(await page.locator('.commit-card-subject').textContent()).toContain('Add patch-test.txt'); expect(await page.locator('.commit-card-meta').textContent()).toContain('Test User'); expect(await page.locator('.commit-card-meta').textContent()).toContain('test@example.com'); expect(await page.locator('.commit-card-meta time').isVisible()).toBe(true); } finally { await page.close(); } }); test('patch description renders markdown', async () => { const page = await adminCtx.newPage(); try { await page.goto(cleanPatchUrl); expect(await page.locator('.timeline-body.markdown-body').innerHTML()).toContain(''); } finally { await page.close(); } }); test('clean patch shows apply-clean status immediately', async () => { const page = await adminCtx.newPage(); try { await page.goto(cleanPatchUrl); expect(await page.locator('.apply-result').isVisible()).toBe(true); expect(await page.locator('.apply-clean').isVisible()).toBe(true); } finally { await page.close(); } }); test('merge button appears for clean patch', async () => { const page = await adminCtx.newPage(); try { await page.goto(cleanPatchUrl); expect(await page.locator('form[action*="/merge"] button').isVisible()).toBe(true); } finally { await page.close(); } }); test('patch diff is displayed with highlighted table', async () => { const page = await adminCtx.newPage(); try { await page.goto(cleanPatchUrl + '?tab=changes'); expect(await page.locator('.diff-table').first().isVisible()).toBe(true); expect(await page.locator('.diff-row-add').count()).toBeGreaterThan(0); } finally { await page.close(); } }); test('patch appears in open list', async () => { const page = await adminCtx.newPage(); try { await page.goto(`${BASE}/my-repo/patches`); const titles = await page.locator('.issue-title').allTextContents(); expect(titles.some(t => t.includes('Add patch-test.txt'))).toBe(true); } finally { await page.close(); } }); test('unauthenticated user is redirected to login from patch upload', async () => { const ctx = await browser.newContext(); const page = await ctx.newPage(); try { await page.goto(`${BASE}/my-repo/patches/new`); expect(page.url()).toContain('/login'); } finally { await ctx.close(); } }); test('upload conflict patch', async () => { writeTempFile('/tmp/conflict.patch', CONFLICT_PATCH); const page = await adminCtx.newPage(); try { await page.goto(`${BASE}/my-repo/patches/new`); await page.fill('[name=title]', 'Conflict patch'); await page.locator('[name=patch_file]').setInputFiles('/tmp/conflict.patch'); await page.click('form[action$="/patches"] button[type=submit]'); await page.waitForURL(/\/my-repo\/patches\/\d+/); conflictPatchUrl = page.url(); } finally { await page.close(); } }); test('conflict patch shows apply-conflict status', async () => { const page = await adminCtx.newPage(); try { await page.goto(conflictPatchUrl); expect(await page.locator('.apply-conflict').isVisible()).toBe(true); } finally { await page.close(); } }); test('merge button absent for conflict patch', async () => { const page = await adminCtx.newPage(); try { await page.goto(conflictPatchUrl); expect(await page.locator('form[action*="/merge"] button').count()).toBe(0); } finally { await page.close(); } }); test('merge clean patch changes status to merged', async () => { const page = await adminCtx.newPage(); try { await page.goto(cleanPatchUrl); await page.click('form[action*="/merge"] button'); await page.waitForURL(new RegExp(cleanPatchUrl.replace(BASE, ''))); expect(await page.locator('.patch-badge').textContent()).toBe('merged'); } finally { await page.close(); } }); test('merge uses patch From header as git author', async () => { const repoPath = `${process.cwd()}/data-test/repos/my-repo.git`; const authorName = (await $`git -C ${repoPath} log -1 --format=%aN`.quiet()).text().trim(); const authorEmail = (await $`git -C ${repoPath} log -1 --format=%aE`.quiet()).text().trim(); const subject = (await $`git -C ${repoPath} log -1 --format=%s`.quiet()).text().trim(); expect(authorName).toBe('Test User'); expect(authorEmail).toBe('test@example.com'); expect(subject).toBe('Add patch-test.txt'); }); test('merged patch appears in merged list', async () => { const page = await adminCtx.newPage(); try { await page.goto(`${BASE}/my-repo/patches?status=merged`); const titles = await page.locator('.issue-title').allTextContents(); expect(titles.some(t => t.includes('Add patch-test.txt'))).toBe(true); } finally { await page.close(); } }); test('upload and close a patch', async () => { writeTempFile('/tmp/close.patch', CLOSE_PATCH); const page = await adminCtx.newPage(); try { await page.goto(`${BASE}/my-repo/patches/new`); await page.fill('[name=title]', 'Close me'); await page.locator('[name=patch_file]').setInputFiles('/tmp/close.patch'); await page.click('form[action$="/patches"] button[type=submit]'); await page.waitForURL(/\/my-repo\/patches\/\d+/); closePatchUrl = page.url(); await page.click('form[action*="/close"] button'); await page.waitForURL(new RegExp(closePatchUrl.replace(BASE, ''))); expect(await page.locator('.patch-badge').textContent()).toBe('closed'); } finally { await page.close(); } }); test('closed patch appears in closed list', async () => { const page = await adminCtx.newPage(); try { await page.goto(`${BASE}/my-repo/patches?status=closed`); const titles = await page.locator('.issue-title').allTextContents(); expect(titles.some(t => t.includes('Close me'))).toBe(true); } finally { await page.close(); } }); test('closed patch can be reopened', async () => { const page = await adminCtx.newPage(); try { await page.goto(closePatchUrl); expect(await page.locator('.patch-badge').textContent()).toBe('closed'); await page.click('form[action*="/close"] button'); await page.waitForURL(new RegExp(closePatchUrl.replace(BASE, ''))); expect(await page.locator('.patch-badge').textContent()).toBe('open'); } finally { await page.close(); } }); test('patch title and description can be edited', async () => { const page = await adminCtx.newPage(); try { await page.goto(conflictPatchUrl); // Edit title via title form await page.click('details.title-edit-details summary'); await page.fill('.title-edit-form-area [name=title]', 'Edited Conflict Patch'); await page.click('.title-edit-form-area [type=submit]'); await page.waitForURL(new RegExp(conflictPatchUrl.replace(BASE, ''))); expect(await page.locator('.issue-detail-title').textContent()).toBe('Edited Conflict Patch'); // Edit description via inline form await page.click('.timeline-author .inline-edit-details summary'); await page.fill('.inline-edit-form-area [name=edit_description]', 'Updated desc'); await page.click('.inline-edit-form-area [type=submit]'); await page.waitForURL(new RegExp(conflictPatchUrl.replace(BASE, ''))); } finally { await page.close(); } }); test('patch comment: add and edit', async () => { const page = await adminCtx.newPage(); try { await page.goto(conflictPatchUrl); await page.fill('[name=body]', 'My patch comment'); await page.click('form[action$="/comments"] button[type=submit]'); await page.waitForURL(new RegExp(conflictPatchUrl.replace(BASE, ''))); expect(await page.locator('.timeline-body').last().textContent()).toContain('My patch comment'); // Edit the comment — scope to the timeline-item containing the comment text const commentItem = page.locator('.timeline-item:not(.timeline-item-new)').filter({ hasText: 'My patch comment' }); await commentItem.locator('.inline-edit-details summary').click(); await commentItem.locator('.inline-edit-form-area [name=edit_body]').fill('Edited patch comment'); await commentItem.locator('.inline-edit-form-area [type=submit]').click(); await page.waitForURL(new RegExp(conflictPatchUrl.replace(BASE, ''))); expect(await page.locator('.timeline-body').last().textContent()).toContain('Edited patch comment'); } finally { await page.close(); } }); test('patch reaction on description', async () => { const page = await adminCtx.newPage(); try { await page.goto(conflictPatchUrl); // Open reaction picker on the first timeline-item (description) await page.locator('.timeline-item').first().locator('.reaction-add-btn').click(); await page.locator('.timeline-item').first().locator('.reaction-picker-btn').first().click(); await page.waitForURL(new RegExp(conflictPatchUrl.replace(BASE, ''))); expect(await page.locator('.reaction-btn').first().textContent()).toMatch(/\d/); } finally { await page.close(); } }); test('admin can delete a patch', async () => { const page = await adminCtx.newPage(); try { const patchNum = conflictPatchUrl.split('/patches/')[1]; const resp = await page.request.post(`${BASE}/my-repo/patches/${patchNum}/delete`, { maxRedirects: 0, }); expect(resp.status()).toBe(302); expect(resp.headers()['location']).toContain('/patches'); // Patch should no longer be accessible const checkResp = await page.request.get(conflictPatchUrl); expect(checkResp.status()).toBe(404); } finally { await page.close(); } }); // ── Patch file re-upload & version protection ────────────────────────────── let uploadTestPatchUrl: string; // Adds upload-test.txt — applies cleanly to my-repo const UPLOAD_TEST_PATCH = [ 'From c1d2e3f4a5b6c1d2e3f4a5b6c1d2e3f4a5b6c1d2 Mon Sep 17 00:00:00 2001', 'From: Original Author ', 'Date: Wed, 03 Jan 2024 10:00:00 +0000', 'Subject: [PATCH] Add upload-test.txt', '', '---', 'diff --git a/upload-test.txt b/upload-test.txt', 'new file mode 100644', 'index 0000000..9daeafb', '--- /dev/null', '+++ b/upload-test.txt', '@@ -0,0 +1 @@', '+upload test', '', ].join('\n'); // Replacement: different author, same diff target const REPLACEMENT_PATCH = [ 'From d1e2f3a4b5c6d1e2f3a4b5c6d1e2f3a4b5c6d1e2 Mon Sep 17 00:00:00 2001', 'From: Replaced Author ', 'Date: Thu, 04 Jan 2024 10:00:00 +0000', 'Subject: [PATCH] Add upload-test.txt (v2)', '', '---', 'diff --git a/upload-test.txt b/upload-test.txt', 'new file mode 100644', 'index 0000000..9daeafb', '--- /dev/null', '+++ b/upload-test.txt', '@@ -0,0 +1 @@', '+upload test v2', '', ].join('\n'); test('create patch for re-upload tests', async () => { writeTempFile('/tmp/upload-test.patch', UPLOAD_TEST_PATCH); const page = await adminCtx.newPage(); try { await page.goto(`${BASE}/my-repo/patches/new`); await page.fill('[name=title]', 'Upload test patch'); await page.locator('[name=patch_file]').setInputFiles('/tmp/upload-test.patch'); await page.click('form[action$="/patches"] button[type=submit]'); await page.waitForURL(/\/my-repo\/patches\/\d+/); uploadTestPatchUrl = page.url(); } finally { await page.close(); } }); test('upload patch file button is visible for admin on open patch', async () => { const page = await adminCtx.newPage(); try { await page.goto(uploadTestPatchUrl); expect(await page.locator('details:has([name=patch_file])').count()).toBe(1); } finally { await page.close(); } }); test('non-author non-admin cannot upload patch file', async () => { const aliceCtx = await loggedInContext('alice', 'password123'); const page = await aliceCtx.newPage(); try { const patchNum = uploadTestPatchUrl.split('/patches/')[1]; const resp = await page.request.post(`${BASE}/my-repo/patches/${patchNum}/upload`, { multipart: { patch_file: { name: 'test.patch', mimeType: 'text/plain', buffer: Buffer.from(UPLOAD_TEST_PATCH) } }, maxRedirects: 0, }); expect(resp.status()).toBe(403); } finally { await aliceCtx.close(); } }); test('upload button hidden for non-author non-admin', async () => { const aliceCtx = await loggedInContext('alice', 'password123'); const page = await aliceCtx.newPage(); try { await page.goto(uploadTestPatchUrl); expect(await page.locator('details:has([name=patch_file])').count()).toBe(0); } finally { await aliceCtx.close(); } }); test('admin can upload replacement patch file', async () => { writeTempFile('/tmp/replacement.patch', REPLACEMENT_PATCH); const page = await adminCtx.newPage(); try { await page.goto(uploadTestPatchUrl); await page.locator('details:has([name=patch_file]) summary').click(); await page.locator('[name=patch_file]').setInputFiles('/tmp/replacement.patch'); await page.locator('details:has([name=patch_file]) button[type=submit]').click(); await page.waitForURL(new RegExp(uploadTestPatchUrl.replace(BASE, ''))); // Author info should reflect the replacement patch expect(await page.locator('.patch-author-identity').textContent()).toContain('Replaced Author'); expect(await page.locator('.patch-author-identity').textContent()).toContain('replaced@example.com'); } finally { await page.close(); } }); test('merge fails when version token is stale', async () => { // Patch that adds stale-version.txt — applies cleanly const STALE_PATCH = [ 'From e1f2a3b4c5d6e1f2a3b4c5d6e1f2a3b4c5d6e1f2 Mon Sep 17 00:00:00 2001', 'From: Test User ', 'Date: Fri, 05 Jan 2024 10:00:00 +0000', 'Subject: [PATCH] Add stale-version.txt', '', '---', 'diff --git a/stale-version.txt b/stale-version.txt', 'new file mode 100644', 'index 0000000..9daeafb', '--- /dev/null', '+++ b/stale-version.txt', '@@ -0,0 +1 @@', '+stale', '', ].join('\n'); const STALE_PATCH_V2 = STALE_PATCH .replace('Add stale-version.txt', 'Add stale-version.txt (v2)') .replace('+stale', '+stale v2'); writeTempFile('/tmp/stale.patch', STALE_PATCH); const page = await adminCtx.newPage(); try { // Create the patch await page.goto(`${BASE}/my-repo/patches/new`); await page.fill('[name=title]', 'Stale version test'); await page.locator('[name=patch_file]').setInputFiles('/tmp/stale.patch'); await page.click('form[action$="/patches"] button[type=submit]'); await page.waitForURL(/\/my-repo\/patches\/\d+/); const stalePatchUrl = page.url(); const patchNum = stalePatchUrl.split('/patches/')[1]; // Capture the version the admin sees on the page const staleVersion = await page.locator('form[action*="/merge"] [name=version]').inputValue(); // Author uploads a new patch file (simulated by admin here), bumping the version writeTempFile('/tmp/stale-v2.patch', STALE_PATCH_V2); await page.locator('details:has([name=patch_file]) summary').click(); await page.locator('[name=patch_file]').setInputFiles('/tmp/stale-v2.patch'); await page.locator('details:has([name=patch_file]) button[type=submit]').click(); await page.waitForURL(new RegExp(stalePatchUrl.replace(BASE, ''))); // Admin tries to merge with the stale version — should be rejected const resp = await page.request.post(`${BASE}/my-repo/patches/${patchNum}/merge`, { form: { version: staleVersion }, maxRedirects: 0, }); expect(resp.status()).toBe(409); expect(await resp.text()).toContain('updated'); // Patch status must still be open const checkResp = await page.request.get(stalePatchUrl); expect(checkResp.status()).toBe(200); expect(await checkResp.text()).toContain('open'); } finally { await page.close(); } }); test('merge succeeds with current version token after replacement upload', async () => { const page = await adminCtx.newPage(); try { await page.goto(uploadTestPatchUrl); await page.click('form[action*="/merge"] button'); await page.waitForURL(new RegExp(uploadTestPatchUrl.replace(BASE, ''))); expect(await page.locator('.patch-badge').textContent()).toBe('merged'); // Merged commit should carry the replacement patch's author const repoPath = `${process.cwd()}/data-test/repos/my-repo.git`; const authorName = (await $`git -C ${repoPath} log -1 --format=%aN`.quiet()).text().trim(); expect(authorName).toBe('Replaced Author'); } finally { await page.close(); } }); test('upload patch file button hidden on merged patch', async () => { const page = await adminCtx.newPage(); try { await page.goto(uploadTestPatchUrl); expect(await page.locator('details:has([name=patch_file])').count()).toBe(0); } finally { await page.close(); } }); test('POST to upload on merged patch returns 400', async () => { const patchNum = uploadTestPatchUrl.split('/patches/')[1]; const resp = await adminCtx.request.post(`${BASE}/my-repo/patches/${patchNum}/upload`, { multipart: { patch_file: { name: 'test.patch', mimeType: 'text/plain', buffer: Buffer.from(UPLOAD_TEST_PATCH) } }, maxRedirects: 0, }); expect(resp.status()).toBe(400); }); }); // ─── Pagination ─────────────────────────────────────────────────────────────── describe('pagination', () => { let adminCtx: BrowserContext; beforeAll(async () => { adminCtx = await loggedInContext(); // Create a repo dedicated to pagination testing const page = await adminCtx.newPage(); try { await page.goto(`${BASE}/new`); await page.fill('[name=name]', 'paged-repo'); await page.click('form[action="/new"] button[type=submit]'); await page.waitForURL(`${BASE}/paged-repo`); } finally { await page.close(); } // Create 21 issues via the API (triggers page 2 at 20 per page) await bulkCreateIssues(adminCtx, 'paged-repo', 21); // Create 21 patches via browser (patch upload requires multipart) await bulkCreatePatches(adminCtx, 'paged-repo', 21); }); afterAll(async () => { await adminCtx.close(); }); // ── Repo list pagination ────────────────────────────────────────────────── test('repo list page 1 shows repos and no pagination when few repos', async () => { // With only a handful of test repos (< 20), there should be no pagination nav const page = await adminCtx.newPage(); try { await page.goto(BASE); // Repos are shown expect(await page.locator('.repo-name').count()).toBeGreaterThan(0); } finally { await page.close(); } }); // ── Issue pagination ────────────────────────────────────────────────────── test('issue list page 1 shows at most 20 items', async () => { const page = await adminCtx.newPage(); try { await page.goto(`${BASE}/paged-repo/issues`); expect(await page.locator('.issue-item').count()).toBeLessThanOrEqual(20); } finally { await page.close(); } }); test('issue list pagination nav appears when more than 20 issues', async () => { const page = await adminCtx.newPage(); try { await page.goto(`${BASE}/paged-repo/issues`); expect(await page.locator('.pagination').isVisible()).toBe(true); } finally { await page.close(); } }); test('issue list page 2 shows remaining issues', async () => { const page = await adminCtx.newPage(); try { await page.goto(`${BASE}/paged-repo/issues?page=2`); const count = await page.locator('.issue-item').count(); expect(count).toBeGreaterThan(0); expect(count).toBeLessThanOrEqual(20); } finally { await page.close(); } }); test('issue list page 2 prev link goes to page 1', async () => { const page = await adminCtx.newPage(); try { await page.goto(`${BASE}/paged-repo/issues?page=2`); const prevHref = await page.locator('.pagination-prev .pagination-btn').getAttribute('href'); expect(prevHref).toContain('page=1'); } finally { await page.close(); } }); test('issue list page 1 next link goes to page 2', async () => { const page = await adminCtx.newPage(); try { await page.goto(`${BASE}/paged-repo/issues`); const nextHref = await page.locator('.pagination-next .pagination-btn').getAttribute('href'); expect(nextHref).toContain('page=2'); } finally { await page.close(); } }); // ── Patch pagination ────────────────────────────────────────────────────── test('patch list page 1 shows at most 20 items', async () => { const page = await adminCtx.newPage(); try { await page.goto(`${BASE}/paged-repo/patches`); expect(await page.locator('.issue-item').count()).toBeLessThanOrEqual(20); } finally { await page.close(); } }); test('patch list pagination nav appears when more than 20 patches', async () => { const page = await adminCtx.newPage(); try { await page.goto(`${BASE}/paged-repo/patches`); expect(await page.locator('.pagination').isVisible()).toBe(true); } finally { await page.close(); } }); test('patch list page 2 shows remaining patches', async () => { const page = await adminCtx.newPage(); try { await page.goto(`${BASE}/paged-repo/patches?page=2`); const count = await page.locator('.issue-item').count(); expect(count).toBeGreaterThan(0); } finally { await page.close(); } }); // ── Commit log pagination ───────────────────────────────────────────────── test('commit log with few commits shows no cursor nav', async () => { // my-repo has 1 commit — both newer and older links should be absent const page = await adminCtx.newPage(); try { await page.goto(`${BASE}/my-repo/commits/main`); expect(await page.locator('.commit-cursor-nav').count()).toBe(0); } finally { await page.close(); } }); }); // ─── Branch selector ────────────────────────────────────────────────────────── describe('branch selector', () => { let adminCtx: BrowserContext; beforeAll(async () => { adminCtx = await loggedInContext(); // Add a second branch so the selector is meaningful await seedBranch('my-repo', 'dev'); }); afterAll(async () => { await adminCtx.close(); }); test('branch selector appears on repo home', async () => { const page = await adminCtx.newPage(); try { await page.goto(`${BASE}/my-repo`); expect(await page.locator('.branch-selector').isVisible()).toBe(true); expect(await page.locator('.branch-select').inputValue()).toBe('main'); } finally { await page.close(); } }); test('branch selector shows all branches on repo home', async () => { const page = await adminCtx.newPage(); try { await page.goto(`${BASE}/my-repo`); const options = await page.locator('.branch-select option').allTextContents(); expect(options).toContain('main'); expect(options).toContain('dev'); } finally { await page.close(); } }); test('branch selector appears on file tree with current ref selected', async () => { const page = await adminCtx.newPage(); try { await page.goto(`${BASE}/my-repo/tree/main`); expect(await page.locator('.branch-selector').isVisible()).toBe(true); expect(await page.locator('.branch-select').inputValue()).toBe('main'); } finally { await page.close(); } }); test('branch selector appears on commit log with current ref selected', async () => { const page = await adminCtx.newPage(); try { await page.goto(`${BASE}/my-repo/commits/main`); expect(await page.locator('.branch-selector').isVisible()).toBe(true); expect(await page.locator('.branch-select').inputValue()).toBe('main'); } finally { await page.close(); } }); test('branch selector appears on file blob', async () => { const page = await adminCtx.newPage(); try { await page.goto(`${BASE}/my-repo/blob/main/README.md`); expect(await page.locator('.branch-selector').isVisible()).toBe(true); expect(await page.locator('.branch-select').inputValue()).toBe('main'); } finally { await page.close(); } }); test('switching branch on commit log navigates to the selected branch', async () => { const page = await adminCtx.newPage(); try { await page.goto(`${BASE}/my-repo/commits/main`); await page.locator('.branch-select').selectOption('dev'); await page.locator('form.branch-selector').evaluate((f: any) => f.submit()); await page.waitForURL(`${BASE}/my-repo/commits/dev`); expect(page.url()).toContain('/commits/dev'); } finally { await page.close(); } }); test('switching branch on file tree navigates to the selected branch', async () => { const page = await adminCtx.newPage(); try { await page.goto(`${BASE}/my-repo/tree/main`); await page.locator('.branch-select').selectOption('dev'); await page.locator('form.branch-selector').evaluate((f: any) => f.submit()); await page.waitForURL(`${BASE}/my-repo/tree/dev`); expect(page.url()).toContain('/tree/dev'); } finally { await page.close(); } }); test('branch-switch route preserves subpath when switching tree', async () => { const page = await adminCtx.newPage(); try { const resp = await page.request.get( `${BASE}/my-repo/branch-switch?view=tree&rev=dev&path=src/foo`, { maxRedirects: 0 }, ).catch(r => r); // 302 redirect to /my-repo/tree/dev/src/foo const loc = (resp as any).headers()?.['location'] ?? ''; expect(loc).toContain('/tree/dev/src/foo'); } finally { await page.close(); } }); test('branch-switch route redirects commits view correctly', async () => { const page = await adminCtx.newPage(); try { const resp = await page.request.get( `${BASE}/my-repo/branch-switch?view=commits&rev=dev`, { maxRedirects: 0 }, ).catch(r => r); const loc = (resp as any).headers()?.['location'] ?? ''; expect(loc).toContain('/commits/dev'); } finally { await page.close(); } }); test('branch-switch route redirects blob view correctly', async () => { const page = await adminCtx.newPage(); try { const resp = await page.request.get( `${BASE}/my-repo/branch-switch?view=blob&rev=dev&path=README.md`, { maxRedirects: 0 }, ).catch(r => r); const loc = (resp as any).headers()?.['location'] ?? ''; expect(loc).toContain('/blob/dev/README.md'); } finally { await page.close(); } }); }); // ─── Default branch settings ────────────────────────────────────────────────── describe('default branch settings', () => { // Runs after 'branch selector', so my-repo already has both main and dev branches. let adminCtx: BrowserContext; beforeAll(async () => { adminCtx = await loggedInContext(); }); afterAll(async () => { await adminCtx.close(); }); test('settings page shows default branch select', async () => { const page = await adminCtx.newPage(); try { await page.goto(`${BASE}/my-repo/settings`); expect(await page.locator('#default_branch').isVisible()).toBe(true); const options = await page.locator('#default_branch option').allTextContents(); expect(options).toContain('main'); expect(options).toContain('dev'); } finally { await page.close(); } }); test('current default branch is pre-selected', async () => { const page = await adminCtx.newPage(); try { await page.goto(`${BASE}/my-repo/settings`); expect(await page.locator('#default_branch').inputValue()).toBe('main'); } finally { await page.close(); } }); test('changing default branch saves and is reflected in the repo home', async () => { const page = await adminCtx.newPage(); try { await page.goto(`${BASE}/my-repo/settings`); await page.locator('#default_branch').selectOption('dev'); await page.click('form[action$="/settings"] button[type=submit]'); expect(await page.locator('.form-success').isVisible()).toBe(true); // The select now shows dev as current expect(await page.locator('#default_branch').inputValue()).toBe('dev'); // Repo home branch selector should reflect the new default await page.goto(`${BASE}/my-repo`); expect(await page.locator('.branch-select').inputValue()).toBe('dev'); } finally { await page.close(); } }); test('commit log link in repo nav uses the new default branch', async () => { const page = await adminCtx.newPage(); try { await page.goto(`${BASE}/my-repo`); const commitsHref = await page.locator('.repo-tab[href*="/commits/"]').getAttribute('href'); expect(commitsHref).toContain('/commits/dev'); } finally { await page.close(); } }); test('changing default branch back to main restores original state', async () => { const page = await adminCtx.newPage(); try { await page.goto(`${BASE}/my-repo/settings`); await page.locator('#default_branch').selectOption('main'); await page.click('form[action$="/settings"] button[type=submit]'); expect(await page.locator('.form-success').isVisible()).toBe(true); expect(await page.locator('#default_branch').inputValue()).toBe('main'); } finally { await page.close(); } }); test('settings page shows hint instead of select when repo has no branches', async () => { // Create an empty repo (no commits → no branches) const page = await adminCtx.newPage(); try { await page.goto(`${BASE}/new`); await page.fill('[name=name]', 'empty-for-branch-test'); await page.click('form[action="/new"] button[type=submit]'); await page.waitForURL(`${BASE}/empty-for-branch-test`); await page.goto(`${BASE}/empty-for-branch-test/settings`); expect(await page.locator('#default_branch').count()).toBe(0); expect(await page.locator('.form-hint').isVisible()).toBe(true); } finally { await page.close(); } }); }); // ─── File browser ───────────────────────────────────────────────────────────── describe('file browser', () => { // my-repo already has README.md + index.js from the 'repos' describe block. // We add a subdirectory here so we can test directory navigation. let adminCtx: BrowserContext; beforeAll(async () => { adminCtx = await loggedInContext(); await seedSubdir('my-repo', 'src', { 'app.ts': 'export {};\n', 'README.md': '# src readme\n', }); }); afterAll(async () => { await adminCtx.close(); }); test('repo home shows file tree instead of recent commits', async () => { const page = await adminCtx.newPage(); try { await page.goto(`${BASE}/my-repo`); expect(await page.locator('.file-tree').isVisible()).toBe(true); expect(await page.locator('.repo-commits-section').count()).toBe(0); } finally { await page.close(); } }); test('repo home file tree lists files and directories', async () => { const page = await adminCtx.newPage(); try { await page.goto(`${BASE}/my-repo`); const names = await page.locator('.file-name a').allTextContents(); expect(names).toContain('README.md'); expect(names).toContain('index.js'); expect(names).toContain('src'); } finally { await page.close(); } }); test('directories appear before files in file tree', async () => { const page = await adminCtx.newPage(); try { await page.goto(`${BASE}/my-repo`); const names = await page.locator('.file-name a').allTextContents(); const srcIdx = names.indexOf('src'); const readmeIdx = names.indexOf('README.md'); expect(srcIdx).toBeGreaterThanOrEqual(0); expect(readmeIdx).toBeGreaterThanOrEqual(0); expect(srcIdx).toBeLessThan(readmeIdx); } finally { await page.close(); } }); test('no ".." entry at repository root', async () => { const page = await adminCtx.newPage(); try { await page.goto(`${BASE}/my-repo`); const names = await page.locator('.file-name a').allTextContents(); expect(names).not.toContain('..'); } finally { await page.close(); } }); test('clicking directory navigates into it', async () => { const page = await adminCtx.newPage(); try { await page.goto(`${BASE}/my-repo`); await page.locator('.file-name a', { hasText: 'src' }).click(); await page.waitForURL(`${BASE}/my-repo/tree/main/src`); expect(page.url()).toContain('/tree/main/src'); } finally { await page.close(); } }); test('".." entry appears in subdirectory', async () => { const page = await adminCtx.newPage(); try { await page.goto(`${BASE}/my-repo/tree/main/src`); const names = await page.locator('.file-name a').allTextContents(); expect(names).toContain('..'); } finally { await page.close(); } }); test('".." at one level deep links to tree root', async () => { const page = await adminCtx.newPage(); try { await page.goto(`${BASE}/my-repo/tree/main/src`); const upHref = await page.locator('.file-name a', { hasText: '..' }).getAttribute('href'); expect(upHref).toBe('/my-repo/tree/main'); } finally { await page.close(); } }); test('files in subdirectory show plain names, not full paths', async () => { const page = await adminCtx.newPage(); try { await page.goto(`${BASE}/my-repo/tree/main/src`); const names = await page.locator('.file-name a').allTextContents(); expect(names).toContain('app.ts'); // Must NOT contain the full path with prefix expect(names).not.toContain('src/app.ts'); expect(names).not.toContain('src/README.md'); } finally { await page.close(); } }); test('readme is shown below file tree on repo home', async () => { const page = await adminCtx.newPage(); try { await page.goto(`${BASE}/my-repo`); const treeBox = await page.locator('.file-tree').boundingBox(); const readmeBox = await page.locator('.readme-section').boundingBox(); expect(treeBox).not.toBeNull(); expect(readmeBox).not.toBeNull(); expect(readmeBox!.y).toBeGreaterThan(treeBox!.y + treeBox!.height - 1); } finally { await page.close(); } }); test('readme in subdirectory is shown when present', async () => { const page = await adminCtx.newPage(); try { await page.goto(`${BASE}/my-repo/tree/main/src`); expect(await page.locator('.readme-section').isVisible()).toBe(true); expect(await page.locator('.readme-section .markdown-body').innerHTML()) .toContain('src readme'); } finally { await page.close(); } }); test('file tree on /tree/:ref also shows readme', async () => { const page = await adminCtx.newPage(); try { await page.goto(`${BASE}/my-repo/tree/main`); expect(await page.locator('.file-tree').isVisible()).toBe(true); expect(await page.locator('.readme-section').isVisible()).toBe(true); } finally { await page.close(); } }); }); // ─── Releases ───────────────────────────────────────────────────────────────── describe('releases', () => { let adminCtx: BrowserContext; let aliceCtx: BrowserContext; let releaseUrl: string; let srcReleaseUrl: string; let releaseWithAssetsUrl: string; beforeAll(async () => { adminCtx = await loggedInContext(); aliceCtx = await loggedInContext('alice', 'password123'); // Create a dedicated repo with at least one commit const page = await adminCtx.newPage(); try { await page.goto(`${BASE}/new`); await page.fill('[name=name]', 'releases-repo'); await page.click('form[action="/new"] button[type=submit]'); await page.waitForURL(`${BASE}/releases-repo`); } finally { await page.close(); } await seedRepo('releases-repo'); }); afterAll(async () => { await adminCtx.close(); await aliceCtx.close(); }); // ── Navigation ────────────────────────────────────────────────────────────── test('releases tab visible in repo nav', async () => { const page = await adminCtx.newPage(); try { await page.goto(`${BASE}/releases-repo`); expect(await page.locator('.repo-tab', { hasText: 'Releases' }).isVisible()).toBe(true); } finally { await page.close(); } }); test('releases list shows empty state when no releases', async () => { const page = await adminCtx.newPage(); try { await page.goto(`${BASE}/releases-repo/releases`); expect(await page.locator('.empty-state').isVisible()).toBe(true); } finally { await page.close(); } }); // ── Access control ────────────────────────────────────────────────────────── test('non-admin cannot access /releases/new', async () => { const page = await aliceCtx.newPage(); try { const resp = await page.request.get(`${BASE}/releases-repo/releases/new`); expect(resp.status()).toBe(403); } finally { await page.close(); } }); test('non-admin POST to /releases returns 403', async () => { const page = await aliceCtx.newPage(); try { const resp = await page.request.post(`${BASE}/releases-repo/releases`, { multipart: { name: 'Test', tag_name: 'v0.1.0' }, maxRedirects: 0, }); expect(resp.status()).toBe(403); } finally { await page.close(); } }); test('unauthenticated user is redirected to login from /releases/new', async () => { const ctx = await browser.newContext(); const page = await ctx.newPage(); try { await page.goto(`${BASE}/releases-repo/releases/new`); expect(page.url()).toContain('/login'); } finally { await ctx.close(); } }); // ── Validation ────────────────────────────────────────────────────────────── test('missing release title shows error', async () => { const resp = await adminCtx.request.post(`${BASE}/releases-repo/releases`, { multipart: {}, }); expect(await resp.text()).toContain('Release title is required'); }); test('create_tag checked but no tag name shows error', async () => { const resp = await adminCtx.request.post(`${BASE}/releases-repo/releases`, { multipart: { name: 'Test', create_tag: 'on' }, }); expect(await resp.text()).toContain('Tag name is required'); }); // ── Create ────────────────────────────────────────────────────────────────── test('create a basic release', async () => { const resp = await adminCtx.request.post(`${BASE}/releases-repo/releases`, { multipart: { create_tag: 'on', tag_name: 'v1.0.0', revision: 'main', name: 'First release', notes: 'Initial stable release.\n\n- Feature A\n- Feature B', }, maxRedirects: 0, }); expect(resp.status()).toBe(302); const location = resp.headers()['location']!; expect(location).toMatch(/\/releases-repo\/releases\/\d+/); releaseUrl = `${BASE}${location}`; }); test('duplicate tag name shows error', async () => { const resp = await adminCtx.request.post(`${BASE}/releases-repo/releases`, { multipart: { name: 'Duplicate', create_tag: 'on', tag_name: 'v1.0.0', revision: 'main' }, }); expect(await resp.text()).toContain('already exists'); }); // ── List ──────────────────────────────────────────────────────────────────── test('release appears in list with tag badge', async () => { const page = await adminCtx.newPage(); try { await page.goto(`${BASE}/releases-repo/releases`); expect(await page.locator('.release-item-title').textContent()).toContain('First release'); expect(await page.locator('.badge').textContent()).toContain('v1.0.0'); } finally { await page.close(); } }); test('release list shows tag badge', async () => { const page = await adminCtx.newPage(); try { await page.goto(`${BASE}/releases-repo/releases`); expect(await page.locator('.badge').first().textContent()).toContain('v1.0.0'); } finally { await page.close(); } }); test('new release button hidden for non-admin', async () => { const page = await aliceCtx.newPage(); try { await page.goto(`${BASE}/releases-repo/releases`); expect(await page.locator('a[href$="/releases/new"]').count()).toBe(0); } finally { await page.close(); } }); // ── Detail ────────────────────────────────────────────────────────────────── test('release detail shows title and tag badge', async () => { const page = await adminCtx.newPage(); try { await page.goto(releaseUrl); expect(await page.locator('h2.page-title').textContent()).toBe('First release'); expect(await page.locator('.badge').textContent()).toContain('v1.0.0'); } finally { await page.close(); } }); test('release notes rendered in detail view', async () => { const page = await adminCtx.newPage(); try { await page.goto(releaseUrl); expect(await page.locator('.markdown-body').textContent()).toContain('Initial stable release'); } finally { await page.close(); } }); // ── Source archives ───────────────────────────────────────────────────────── test('create release with source code archives', async () => { const resp = await adminCtx.request.post(`${BASE}/releases-repo/releases`, { multipart: { name: 'Source release', create_tag: 'on', tag_name: 'v1.1.0', revision: 'main', include_source_code: 'on' }, maxRedirects: 0, }); expect(resp.status()).toBe(302); const location = resp.headers()['location']!; srcReleaseUrl = `${BASE}${location}`; }); test('zip and tar.gz archives appear in downloads', async () => { const page = await adminCtx.newPage(); try { await page.goto(srcReleaseUrl); const assetNames = await page.locator('.asset-name').allTextContents(); expect(assetNames.some(n => n.endsWith('.zip'))).toBe(true); expect(assetNames.some(n => n.endsWith('.tar.gz'))).toBe(true); } finally { await page.close(); } }); test('source archive download responds with 200', async () => { const page = await adminCtx.newPage(); try { await page.goto(srcReleaseUrl); const zipLink = await page.locator('.asset-name', { hasText: '.zip' }).getAttribute('href'); const resp = await page.request.get(`${BASE}${zipLink}`); expect(resp.status()).toBe(200); } finally { await page.close(); } }); // ── File upload ───────────────────────────────────────────────────────────── test('create release with attached file', async () => { const resp = await adminCtx.request.post(`${BASE}/releases-repo/releases`, { multipart: { name: 'Asset release', create_tag: 'on', tag_name: 'v1.2.0', revision: 'main', files: { name: 'release-asset.txt', mimeType: 'text/plain', buffer: Buffer.from('binary-like content for testing\n'), }, }, maxRedirects: 0, }); expect(resp.status()).toBe(302); const location = resp.headers()['location']!; releaseWithAssetsUrl = `${BASE}${location}`; }); test('uploaded asset appears in downloads with filename and size', async () => { const page = await adminCtx.newPage(); try { await page.goto(releaseWithAssetsUrl); const names = await page.locator('.asset-name').allTextContents(); expect(names.some(n => n.includes('release-asset.txt'))).toBe(true); expect(await page.locator('.asset-size').isVisible()).toBe(true); } finally { await page.close(); } }); test('asset download responds with 200', async () => { const page = await adminCtx.newPage(); try { await page.goto(releaseWithAssetsUrl); const link = await page.locator('.asset-name', { hasText: 'release-asset.txt' }).getAttribute('href'); const resp = await page.request.get(`${BASE}${link}`); expect(resp.status()).toBe(200); } finally { await page.close(); } }); // ── Delete ────────────────────────────────────────────────────────────────── test('non-admin cannot delete a release', async () => { const page = await aliceCtx.newPage(); try { const idMatch = releaseUrl.match(/\/releases\/(\d+)/); const resp = await page.request.post(`${BASE}/releases-repo/releases/${idMatch![1]}/delete`, { maxRedirects: 0, }); expect(resp.status()).toBe(403); } finally { await page.close(); } }); test('admin can delete a release', async () => { const idMatch = releaseUrl.match(/\/releases\/(\d+)/); const resp = await adminCtx.request.post( `${BASE}/releases-repo/releases/${idMatch![1]}/delete`, { maxRedirects: 0 }, ); expect(resp.status()).toBe(302); // Verify it's gone from the list const page = await adminCtx.newPage(); try { await page.goto(`${BASE}/releases-repo/releases`); const titles = await page.locator('.release-item-title').allTextContents(); expect(titles.some(t => t.includes('First release'))).toBe(false); } finally { await page.close(); } }); // ── Pagination ────────────────────────────────────────────────────────────── test('release list shows at most 20 per page', async () => { // Bulk-create 25 releases with a distinct prefix to guarantee > 20 total // regardless of which browser-based tests above succeeded for (let i = 1; i <= 25; i++) { await adminCtx.request.post(`${BASE}/releases-repo/releases`, { multipart: { name: `Page test release ${i}`, create_tag: 'on', tag_name: `v9.${i}.0`, revision: 'main' }, maxRedirects: 0, }).catch(() => {}); } const page = await adminCtx.newPage(); try { await page.goto(`${BASE}/releases-repo/releases`); expect(await page.locator('.issue-item').count()).toBeLessThanOrEqual(20); } finally { await page.close(); } }); test('pagination nav appears with more than 20 releases', async () => { const page = await adminCtx.newPage(); try { await page.goto(`${BASE}/releases-repo/releases`); expect(await page.locator('.pagination').isVisible()).toBe(true); } finally { await page.close(); } }); test('release list page 2 shows remaining releases', async () => { const page = await adminCtx.newPage(); try { await page.goto(`${BASE}/releases-repo/releases?page=2`); const count = await page.locator('.issue-item').count(); expect(count).toBeGreaterThan(0); expect(count).toBeLessThanOrEqual(20); } finally { await page.close(); } }); test('source archive tar.zst appears in downloads', async () => { const page = await adminCtx.newPage(); try { await page.goto(srcReleaseUrl); const assetNames = await page.locator('.asset-name').allTextContents(); expect(assetNames.some(n => n.endsWith('.tar.zst'))).toBe(true); } finally { await page.close(); } }); }); // ─── Settings ───────────────────────────────────────────────────────────────── describe('settings', () => { let adminCtx: BrowserContext; let aliceCtx: BrowserContext; // Public key generated once in beforeAll, reused across SSH key tests let testPubKey: string; beforeAll(async () => { adminCtx = await loggedInContext(); aliceCtx = await loggedInContext('alice', 'password123'); // Generate a throwaway ed25519 key for SSH key tests. // Use Bun.spawn so the empty passphrase arg is passed correctly. const keyPath = '/tmp/hf-e2e-sshkey'; await $`rm -f ${keyPath} ${keyPath}.pub`.quiet().nothrow(); const keygen = Bun.spawn( ['ssh-keygen', '-t', 'ed25519', '-f', keyPath, '-N', '', '-C', 'e2e@hearthforge'], { stdout: 'ignore', stderr: 'ignore' }, ); await keygen.exited; testPubKey = await Bun.file(`${keyPath}.pub`).text(); testPubKey = testPubKey.trim(); await $`rm -f ${keyPath} ${keyPath}.pub`.quiet().nothrow(); }); afterAll(async () => { await adminCtx.close(); await aliceCtx.close(); }); test('settings page requires auth', async () => { const ctx = await browser.newContext(); const page = await ctx.newPage(); try { await page.goto(`${BASE}/settings`); expect(page.url()).toContain('/login'); } finally { await ctx.close(); } }); test('settings page loads for logged-in user', async () => { const page = await adminCtx.newPage(); try { await page.goto(`${BASE}/settings`); expect(await page.locator('h1.page-title').textContent()).toBe('Settings'); } finally { await page.close(); } }); // ── Password ────────────────────────────────────────────────────────────── test('password change with mismatched passwords shows error', async () => { const page = await aliceCtx.newPage(); try { await page.goto(`${BASE}/settings`); await page.fill('[name=new_password]', 'newpass123'); await page.fill('[name=confirm_password]', 'different456'); await page.click('form[action="/settings/password"] button[type=submit]'); await page.waitForURL(/\/settings/); expect(page.url()).toContain('error'); } finally { await page.close(); } }); test('password change with wrong current password shows error', async () => { const page = await aliceCtx.newPage(); try { await page.goto(`${BASE}/settings`); await page.fill('[name=current_password]', 'wrongpassword'); await page.fill('[name=new_password]', 'newpass123'); await page.fill('[name=confirm_password]', 'newpass123'); await page.click('form[action="/settings/password"] button[type=submit]'); await page.waitForURL(/\/settings/); expect(page.url()).toContain('error'); } finally { await page.close(); } }); test('password change too short shows error', async () => { const page = await aliceCtx.newPage(); try { await page.goto(`${BASE}/settings`); await page.fill('[name=current_password]', 'password123'); await page.fill('[name=new_password]', 'short'); await page.fill('[name=confirm_password]', 'short'); await page.click('form[action="/settings/password"] button[type=submit]'); await page.waitForURL(/\/settings/); expect(page.url()).toContain('error'); } finally { await page.close(); } }); // ── SSH keys ────────────────────────────────────────────────────────────── test('add SSH key with unsupported key type shows error', async () => { const page = await adminCtx.newPage(); try { await page.goto(`${BASE}/settings`); await page.fill('#ssh_key_name', 'Bad key'); await page.fill('#ssh_public_key', 'ssh-invalid AAAABBBBCCCC test@test'); await page.click('form[action="/settings/ssh-keys"] button[type=submit]'); await page.waitForURL(/\/settings/); expect(page.url()).toContain('error'); } finally { await page.close(); } }); test('add valid SSH key shows success and key appears in list', async () => { const page = await adminCtx.newPage(); try { await page.goto(`${BASE}/settings`); await page.fill('#ssh_key_name', 'My Laptop'); await page.fill('#ssh_public_key', testPubKey); await page.click('form[action="/settings/ssh-keys"] button[type=submit]'); await page.waitForURL(/\/settings/); expect(page.url()).toContain('success=ssh_key_added'); await page.goto(`${BASE}/settings`); expect(await page.locator('.ssh-key-name').textContent()).toContain('My Laptop'); } finally { await page.close(); } }); test('add duplicate SSH key shows error', async () => { const page = await adminCtx.newPage(); try { await page.goto(`${BASE}/settings`); await page.fill('#ssh_key_name', 'Duplicate'); await page.fill('#ssh_public_key', testPubKey); await page.click('form[action="/settings/ssh-keys"] button[type=submit]'); await page.waitForURL(/\/settings/); expect(page.url()).toContain('error'); } finally { await page.close(); } }); test('delete SSH key removes it from list', async () => { const page = await adminCtx.newPage(); try { await page.goto(`${BASE}/settings`); // Click the Remove button for the key added above await page.click('form[action="/settings/ssh-keys/delete"] button'); await page.waitForURL(/\/settings/); expect(page.url()).toContain('success=ssh_key_deleted'); await page.goto(`${BASE}/settings`); expect(await page.locator('.ssh-key-name').count()).toBe(0); } finally { await page.close(); } }); // ── Admin user management ──────────────────────────────────────────────── test('admin can create a new user account', async () => { const page = await adminCtx.newPage(); try { await page.goto(`${BASE}/settings`); await page.fill('#new_username', 'charlie'); await page.fill('#new_user_password', 'charliepw1'); await page.click('form[action="/admin/users"] button[type=submit]'); await page.waitForURL(/\/settings/); expect(page.url()).toContain('success=user_created'); } finally { await page.close(); } }); test('admin cannot create duplicate username', async () => { const page = await adminCtx.newPage(); try { await page.goto(`${BASE}/settings`); await page.fill('#new_username', 'charlie'); await page.fill('#new_user_password', 'charliepw1'); await page.click('form[action="/admin/users"] button[type=submit]'); await page.waitForURL(/\/settings/); expect(page.url()).toContain('error'); } finally { await page.close(); } }); test('admin cannot create user with invalid username characters', async () => { const resp = await adminCtx.request.post(`${BASE}/admin/users`, { form: { username: 'bad user!', password: 'password123' }, maxRedirects: 0, }); expect(resp.status()).toBe(302); const location = resp.headers()['location'] ?? ''; expect(location).toContain('error'); }); test('non-admin gets 403 when creating user', async () => { const resp = await aliceCtx.request.post(`${BASE}/admin/users`, { form: { username: 'hacker', password: 'password123' }, maxRedirects: 0, }); expect(resp.status()).toBe(403); }); test('admin can delete user account', async () => { const resp = await adminCtx.request.post(`${BASE}/admin/users/delete`, { form: { username: 'charlie' }, maxRedirects: 0, }); expect(resp.status()).toBe(302); expect(resp.headers()['location']).toContain('success=user_deleted'); }); test('admin cannot delete the admin account', async () => { const resp = await adminCtx.request.post(`${BASE}/admin/users/delete`, { form: { username: 'admin' }, maxRedirects: 0, }); expect(resp.status()).toBe(302); expect(resp.headers()['location']).toContain('error'); }); test('settings page has no git identity section', async () => { const page = await adminCtx.newPage(); try { await page.goto(`${BASE}/settings`); expect(await page.locator('text=Git Identity').count()).toBe(0); expect(await page.locator('[name=git_name]').count()).toBe(0); expect(await page.locator('[name=git_email]').count()).toBe(0); } finally { await page.close(); } }); test('git identity route no longer exists', async () => { const resp = await adminCtx.request.post(`${BASE}/settings/git-identity`, { form: { git_name: 'Test', git_email: 'test@example.com' }, maxRedirects: 0, }); expect(resp.status()).toBe(404); }); }); // ─── Repository deletion ────────────────────────────────────────────────────── describe('repository deletion', () => { let adminCtx: BrowserContext; beforeAll(async () => { adminCtx = await loggedInContext(); // Create a repo to delete const page = await adminCtx.newPage(); try { await page.goto(`${BASE}/new`); await page.fill('[name=name]', 'deleteme-repo'); await page.click('form[action="/new"] button[type=submit]'); await page.waitForURL(`${BASE}/deleteme-repo`); } finally { await page.close(); } }); afterAll(async () => { await adminCtx.close(); }); test('admin can delete repository', async () => { const resp = await adminCtx.request.post(`${BASE}/deleteme-repo/settings/delete`, { maxRedirects: 0, }); expect(resp.status()).toBe(302); expect(resp.headers()['location']).toBe('/'); }); test('deleted repository returns 404', async () => { const page = await adminCtx.newPage(); try { const resp = await page.request.get(`${BASE}/deleteme-repo`); expect(resp.status()).toBe(404); } finally { await page.close(); } }); test('deleted repository no longer appears in list', async () => { const page = await adminCtx.newPage(); try { await page.goto(BASE); expect(await page.locator('.repo-name').allTextContents()).not.toContain('deleteme-repo'); } finally { await page.close(); } }); test('non-admin cannot delete repository', async () => { const aliceCtx = await loggedInContext('alice', 'password123'); const page = await aliceCtx.newPage(); try { const resp = await page.request.post(`${BASE}/my-repo/settings/delete`, { maxRedirects: 0, }); expect(resp.status()).toBe(403); } finally { await page.close(); await aliceCtx.close(); } }); }); // ─── 404 handling ───────────────────────────────────────────────────────────── describe('404 handling', () => { let adminCtx: BrowserContext; beforeAll(async () => { adminCtx = await loggedInContext(); }); afterAll(async () => { await adminCtx.close(); }); test('non-existent repository returns 404', async () => { const page = await adminCtx.newPage(); try { const resp = await page.request.get(`${BASE}/no-such-repo`); expect(resp.status()).toBe(404); } finally { await page.close(); } }); test('non-existent issue returns 404', async () => { const page = await adminCtx.newPage(); try { const resp = await page.request.get(`${BASE}/my-repo/issues/99999`); expect(resp.status()).toBe(404); } finally { await page.close(); } }); test('non-existent commit returns 404', async () => { const page = await adminCtx.newPage(); try { const resp = await page.request.get(`${BASE}/my-repo/commit/deadbeefdeadbeefdeadbeefdeadbeefdeadbeef`); expect(resp.status()).toBe(404); } finally { await page.close(); } }); test('non-existent file blob returns 404', async () => { const page = await adminCtx.newPage(); try { const resp = await page.request.get(`${BASE}/my-repo/blob/main/no-such-file.txt`); expect(resp.status()).toBe(404); } finally { await page.close(); } }); test('non-existent patch returns 404', async () => { const page = await adminCtx.newPage(); try { const resp = await page.request.get(`${BASE}/my-repo/patches/99999`); expect(resp.status()).toBe(404); } finally { await page.close(); } }); test('non-existent release returns 404', async () => { const page = await adminCtx.newPage(); try { const resp = await page.request.get(`${BASE}/my-repo/releases/99999`); expect(resp.status()).toBe(404); } finally { await page.close(); } }); }); // ─── Issue editing and deletion ─────────────────────────────────────────────── describe('issue editing', () => { let adminCtx: BrowserContext; let aliceCtx: BrowserContext; let issueUrl: string; beforeAll(async () => { adminCtx = await loggedInContext(); aliceCtx = await loggedInContext('alice', 'password123'); // Create an issue to edit const page = await adminCtx.newPage(); try { await page.goto(`${BASE}/my-repo/issues/new`); await page.fill('[name=title]', 'Issue to edit'); await page.fill('[name=body]', 'Original body.'); await page.click('form[action$="/issues"] button[type=submit]'); await page.waitForURL(/\/my-repo\/issues\/\d+/); issueUrl = page.url(); } finally { await page.close(); } }); afterAll(async () => { await adminCtx.close(); await aliceCtx.close(); }); test('author can edit issue title and body', async () => { const page = await adminCtx.newPage(); try { await page.goto(issueUrl); // Edit title via title form await page.click('details.title-edit-details summary'); await page.fill('.title-edit-form-area [name=title]', 'Edited issue title'); await page.click('.title-edit-form-area [type=submit]'); await page.waitForURL(new RegExp(issueUrl.replace(BASE, ''))); expect(await page.locator('.issue-detail-title').textContent()).toBe('Edited issue title'); // Edit body via inline form await page.click('.timeline-author .inline-edit-details summary'); await page.fill('.inline-edit-form-area [name=edit_body]', 'Updated body text.'); await page.click('.inline-edit-form-area [type=submit]'); await page.waitForURL(new RegExp(issueUrl.replace(BASE, ''))); } finally { await page.close(); } }); test('edited marker appears after editing', async () => { const page = await adminCtx.newPage(); try { await page.goto(issueUrl); expect(await page.locator('time.edited-indicator').count()).toBeGreaterThan(0); } finally { await page.close(); } }); test('non-author non-admin cannot edit issue', async () => { const page = await aliceCtx.newPage(); try { const issueNum = issueUrl.split('/issues/')[1]; const resp = await page.request.post(`${BASE}/my-repo/issues/${issueNum}/edit`, { form: { title: 'Hacked title', edit_body: '' }, maxRedirects: 0, }); expect(resp.status()).toBe(403); } finally { await page.close(); } }); test('author can edit issue comment', async () => { const page = await adminCtx.newPage(); try { await page.goto(issueUrl); // Add a comment first await page.fill('textarea[name=body]', 'Comment to edit.'); await page.click('form[action*="/comments"] button[type=submit]'); await page.waitForURL(new RegExp(issueUrl.replace(BASE, ''))); // Edit the comment const commentItem = page.locator('.timeline-item:not(.timeline-item-new)').filter({ hasText: 'Comment to edit.' }); await commentItem.locator('.inline-edit-details summary').click(); await commentItem.locator('.inline-edit-form-area [name=edit_body]').fill('Edited comment text.'); await commentItem.locator('.inline-edit-form-area [type=submit]').click(); await page.waitForURL(new RegExp(issueUrl.replace(BASE, ''))); expect(await page.locator('.timeline-body').last().textContent()).toContain('Edited comment text.'); } finally { await page.close(); } }); test('non-admin user can create an issue', async () => { const page = await aliceCtx.newPage(); try { await page.goto(`${BASE}/my-repo/issues/new`); await page.fill('[name=title]', "Alice's issue"); await page.click('form[action$="/issues"] button[type=submit]'); await page.waitForURL(/\/my-repo\/issues\/\d+/); expect(await page.locator('.issue-detail-title').textContent()).toBe("Alice's issue"); } finally { await page.close(); } }); test('non-admin cannot comment on a closed issue', async () => { // Close the issue as admin first const issueNum = issueUrl.split('/issues/')[1]; await adminCtx.request.post(`${BASE}/my-repo/issues/${issueNum}/close`, { maxRedirects: 0 }).catch(() => {}); const page = await aliceCtx.newPage(); try { const resp = await page.request.post(`${BASE}/my-repo/issues/${issueNum}/comments`, { form: { body: 'comment on closed issue' }, maxRedirects: 0, }); // Non-admin gets redirected (silently ignored), not an error expect(resp.status()).toBe(302); // The comment should NOT appear await page.goto(issueUrl); const bodies = await page.locator('.timeline-body').allTextContents(); expect(bodies.every(b => !b.includes('comment on closed issue'))).toBe(true); } finally { await page.close(); } }); test('admin can delete issue', async () => { const issueNum = issueUrl.split('/issues/')[1]; const resp = await adminCtx.request.post(`${BASE}/my-repo/issues/${issueNum}/delete`, { maxRedirects: 0, }); expect(resp.status()).toBe(302); // Issue should be gone const page = await adminCtx.newPage(); try { const checkResp = await page.request.get(issueUrl); expect(checkResp.status()).toBe(404); } finally { await page.close(); } }); }); // ─── Repository description update ─────────────────────────────────────────── describe('repo description', () => { let adminCtx: BrowserContext; beforeAll(async () => { adminCtx = await loggedInContext(); }); afterAll(async () => { await adminCtx.close(); }); test('updating repo description is reflected on list page', async () => { const page = await adminCtx.newPage(); try { await page.goto(`${BASE}/my-repo/settings`); await page.fill('[name=description]', 'A freshly updated description'); await page.click('form[action$="/settings"] button[type=submit]'); expect(await page.locator('.form-success').isVisible()).toBe(true); await page.goto(BASE); const desc = await page.locator('.repo-description').allTextContents(); expect(desc.some(d => d.includes('freshly updated description'))).toBe(true); } finally { await page.close(); } }); }); // ─── Issue and patch templates ──────────────────────────────────────────────── describe('issue and patch templates', () => { let adminCtx: BrowserContext; beforeAll(async () => { adminCtx = await loggedInContext(); }); afterAll(async () => { await adminCtx.close(); }); test('issue template can be saved and is prefilled on new issue form', async () => { const page = await adminCtx.newPage(); try { await page.goto(`${BASE}/my-repo/settings`); await page.fill('[name=issue_template]', '## Steps to reproduce\n\n## Expected behavior'); await page.click('form[action$="/settings"] button[type=submit]'); expect(await page.locator('.form-success').isVisible()).toBe(true); await page.goto(`${BASE}/my-repo/issues/new`); const body = await page.locator('[name=body]').inputValue(); expect(body).toContain('## Steps to reproduce'); expect(body).toContain('## Expected behavior'); } finally { await page.close(); } }); test('patch template can be saved and is prefilled on new patch form', async () => { const page = await adminCtx.newPage(); try { await page.goto(`${BASE}/my-repo/settings`); await page.fill('[name=patch_template]', '## Summary\n\n## Testing'); await page.click('form[action$="/settings"] button[type=submit]'); expect(await page.locator('.form-success').isVisible()).toBe(true); await page.goto(`${BASE}/my-repo/patches/new`); const desc = await page.locator('[name=description]').inputValue(); expect(desc).toContain('## Summary'); expect(desc).toContain('## Testing'); } finally { await page.close(); } }); test('clearing the issue template removes prefill', async () => { const page = await adminCtx.newPage(); try { await page.goto(`${BASE}/my-repo/settings`); await page.fill('[name=issue_template]', ''); await page.click('form[action$="/settings"] button[type=submit]'); expect(await page.locator('.form-success').isVisible()).toBe(true); await page.goto(`${BASE}/my-repo/issues/new`); const body = await page.locator('[name=body]').inputValue(); expect(body).toBe(''); } finally { await page.close(); } }); test('clearing the patch template removes prefill', async () => { const page = await adminCtx.newPage(); try { await page.goto(`${BASE}/my-repo/settings`); await page.fill('[name=patch_template]', ''); await page.click('form[action$="/settings"] button[type=submit]'); expect(await page.locator('.form-success').isVisible()).toBe(true); await page.goto(`${BASE}/my-repo/patches/new`); const desc = await page.locator('[name=description]').inputValue(); expect(desc).toBe(''); } finally { await page.close(); } }); }); // ─── Commit signing ─────────────────────────────────────────────────────────── // Depends on the 'patches' block having already merged CLEAN_PATCH into my-repo. describe('commit signing', () => { let adminCtx: BrowserContext; beforeAll(async () => { adminCtx = await loggedInContext(); }); afterAll(async () => { await adminCtx.close(); }); test('allowed_signers file is generated at startup', () => { const allowedSignersPath = `${process.cwd()}/${DATA_DIR}/allowed_signers`; expect(existsSync(allowedSignersPath)).toBe(true); const content = readFileSync(allowedSignersPath, 'utf8'); expect(content).toContain('namespaces="git"'); expect(content).toContain('ssh-ed25519'); }); test('merged commit has a gpgsig header', async () => { const repoDir = `${process.cwd()}/${DATA_DIR}/repos/my-repo.git`; // Find the patch commit specifically by subject const hash = (await $`git -C ${repoDir} log --format=%H --grep="Add patch-test.txt" -1`.quiet()).text().trim(); expect(hash).toBeTruthy(); const obj = (await $`git -C ${repoDir} cat-file -p ${hash}`.quiet()).text(); expect(obj).toContain('gpgsig'); }); test('unsigned commits have no gpgsig header', async () => { const repoDir = `${process.cwd()}/${DATA_DIR}/repos/my-repo.git`; // Initial commit was created by seedRepo (plain git commit, not hearthforge) const hash = (await $`git -C ${repoDir} log --format=%H --grep="Initial commit" -1`.quiet()).text().trim(); expect(hash).toBeTruthy(); const obj = (await $`git -C ${repoDir} cat-file -p ${hash}`.quiet()).text(); expect(obj).not.toContain('gpgsig'); }); test('commit log shows verified badge on signed commit', async () => { const page = await adminCtx.newPage(); try { await page.goto(`${BASE}/my-repo/commits/main`); // Find the commit-item for the merged patch by subject text const patchItem = page.locator('.commit-item').filter({ hasText: 'Add patch-test.txt' }); expect(await patchItem.locator('.sig-badge.verified').isVisible()).toBe(true); } finally { await page.close(); } }); test('commit log shows no sig badge on unsigned commit', async () => { const page = await adminCtx.newPage(); try { await page.goto(`${BASE}/my-repo/commits/main`); // Initial commit was not signed via hearthforge const initialItem = page.locator('.commit-item').filter({ hasText: 'Initial commit' }); expect(await initialItem.locator('.sig-badge').count()).toBe(0); } finally { await page.close(); } }); test('commit detail shows verified signature row for signed commit', async () => { const repoDir = `${process.cwd()}/${DATA_DIR}/repos/my-repo.git`; const hash = (await $`git -C ${repoDir} log --format=%H --grep="Add patch-test.txt" -1`.quiet()).text().trim(); const page = await adminCtx.newPage(); try { await page.goto(`${BASE}/my-repo/commit/${hash}`); const sigRow = page.locator('.commit-card-meta-row').filter({ hasText: 'Signature' }); expect(await sigRow.isVisible()).toBe(true); expect(await sigRow.locator('.sig-badge.verified').isVisible()).toBe(true); } finally { await page.close(); } }); test('commit detail shows no signature row for unsigned commit', async () => { const repoDir = `${process.cwd()}/${DATA_DIR}/repos/my-repo.git`; const hash = (await $`git -C ${repoDir} log --format=%H --grep="Initial commit" -1`.quiet()).text().trim(); const page = await adminCtx.newPage(); try { await page.goto(`${BASE}/my-repo/commit/${hash}`); const sigRow = page.locator('.commit-card-meta-row').filter({ hasText: 'Signature' }); expect(await sigRow.count()).toBe(0); } finally { await page.close(); } }); }); // ─── Repo sorting and pinning ───────────────────────────────────────────────── describe('repo sorting and pinning', () => { let adminCtx: BrowserContext; beforeAll(async () => { adminCtx = await loggedInContext(); // Create two repos with predictable names: sort-aaa (created first/older), // sort-zzz (created second/newer). This lets us verify both name order and // creation-time order independently. const page = await adminCtx.newPage(); try { await page.goto(`${BASE}/new`); await page.fill('[name=name]', 'sort-aaa'); await page.click('form[action="/new"] button[type=submit]'); await page.waitForURL(`${BASE}/sort-aaa`); await page.goto(`${BASE}/new`); await page.fill('[name=name]', 'sort-zzz'); await page.click('form[action="/new"] button[type=submit]'); await page.waitForURL(`${BASE}/sort-zzz`); } finally { await page.close(); } }); afterAll(async () => { await adminCtx.close(); }); test('sort dropdown is visible on repo list page', async () => { const page = await adminCtx.newPage(); try { await page.goto(BASE); const options = await page.locator('.repo-sort-select option').allTextContents(); expect(options.some(t => t.includes('Newest'))).toBe(true); expect(options.some(t => t.includes('Name'))).toBe(true); } finally { await page.close(); } }); test('newest option is selected by default', async () => { // Use a fresh context to ensure no repo_sort cookie is set. const ctx = await browser.newContext(); const page = await ctx.newPage(); try { await login(page, 'admin', ADMIN_PASS); await page.goto(BASE); expect(await page.locator('.repo-sort-select').inputValue()).toBe('created'); } finally { await ctx.close(); } }); test('Go button is hidden with JS and works without JS', async () => { const ctx = await browser.newContext({ javaScriptEnabled: false }); const page = await ctx.newPage(); try { await login(page, 'admin', ADMIN_PASS); await page.goto(BASE); // Go button visible without JS expect(await page.locator('form[action="/sort"] button[type=submit]').isVisible()).toBe(true); // Select name sort and submit via Go button await page.locator('.repo-sort-select').selectOption('name'); await page.locator('form[action="/sort"] button[type=submit]').click(); await page.waitForURL(BASE + '/'); expect(await page.locator('.repo-sort-select').inputValue()).toBe('name'); } finally { await ctx.close(); } }); test('selecting name sort sets cookie and persists on next visit', async () => { const page = await adminCtx.newPage(); try { await page.goto(BASE); await Promise.all([ page.waitForURL(BASE + '/'), page.locator('.repo-sort-select').selectOption('name'), ]); expect(await page.locator('.repo-sort-select').inputValue()).toBe('name'); // Navigate away and back to confirm cookie persists await page.goto(`${BASE}/my-repo`); await page.goto(BASE); expect(await page.locator('.repo-sort-select').inputValue()).toBe('name'); } finally { // Reset cookie so subsequent tests start from the default sort. await adminCtx.addCookies([{ name: 'repo_sort', value: 'created', domain: 'localhost', path: '/' }]); await page.close(); } }); test('default sort shows newer repo before older repo', async () => { const page = await adminCtx.newPage(); try { await adminCtx.addCookies([{ name: 'repo_sort', value: 'created', domain: 'localhost', path: '/' }]); await page.goto(`${BASE}/?q=sort-`); const names = await page.locator('.repo-name').allTextContents(); expect(names.indexOf('sort-zzz')).toBeLessThan(names.indexOf('sort-aaa')); } finally { await page.close(); } }); test('name sort shows repos in alphabetical order', async () => { const page = await adminCtx.newPage(); try { await adminCtx.addCookies([{ name: 'repo_sort', value: 'name', domain: 'localhost', path: '/' }]); await page.goto(`${BASE}/?q=sort-`); const names = await page.locator('.repo-name').allTextContents(); expect(names.indexOf('sort-aaa')).toBeLessThan(names.indexOf('sort-zzz')); } finally { await adminCtx.addCookies([{ name: 'repo_sort', value: 'created', domain: 'localhost', path: '/' }]); await page.close(); } }); test('pinning a repo shows pinned badge on list page', async () => { const page = await adminCtx.newPage(); try { await page.goto(`${BASE}/sort-aaa/settings`); await page.check('[name=is_pinned]'); await page.click('form[action$="/settings"] button[type=submit]'); expect(await page.locator('.form-success').isVisible()).toBe(true); await page.goto(BASE); const card = page.locator('.repo-card').filter({ hasText: 'sort-aaa' }); expect(await card.locator('.badge-pinned').isVisible()).toBe(true); } finally { await page.close(); } }); test('pinned repo appears before unpinned repos regardless of creation order', async () => { const page = await adminCtx.newPage(); try { // sort-aaa is pinned; default (newest) sort would normally show sort-zzz // first since it's newer — but pinned repos float to the top. await page.goto(`${BASE}/?q=sort-`); const names = await page.locator('.repo-name').allTextContents(); expect(names.indexOf('sort-aaa')).toBeLessThan(names.indexOf('sort-zzz')); } finally { await page.close(); } }); test('unpinning a repo removes the pinned badge', async () => { const page = await adminCtx.newPage(); try { await page.goto(`${BASE}/sort-aaa/settings`); await page.uncheck('[name=is_pinned]'); await page.click('form[action$="/settings"] button[type=submit]'); expect(await page.locator('.form-success').isVisible()).toBe(true); await page.goto(BASE); const card = page.locator('.repo-card').filter({ hasText: 'sort-aaa' }); expect(await card.locator('.badge-pinned').count()).toBe(0); } finally { await page.close(); } }); }); // ─── File editing ───────────────────────────────────────────────────────────── describe('file editing', () => { let adminCtx: BrowserContext; beforeAll(async () => { adminCtx = await loggedInContext(); // Create a dedicated repo so edits don't interfere with other tests const page = await adminCtx.newPage(); try { await page.goto(`${BASE}/new`); await page.fill('[name=name]', 'edit-repo'); await page.click('form[action="/new"] button[type=submit]'); await page.waitForURL(`${BASE}/edit-repo`); } finally { await page.close(); } await seedRepo('edit-repo'); }); afterAll(async () => { await adminCtx.close(); }); test('Edit button appears on text file blob when viewing a branch as admin', async () => { const page = await adminCtx.newPage(); try { await page.goto(`${BASE}/edit-repo/blob/main/index.js`); const editBtn = page.locator('a[href*="/edit/main/index.js"]'); expect(await editBtn.isVisible()).toBe(true); expect(await editBtn.textContent()).toBe('Edit'); } finally { await page.close(); } }); test('Edit button does not appear when viewing a commit SHA', async () => { const sha = await getHeadCommit('edit-repo'); const page = await adminCtx.newPage(); try { await page.goto(`${BASE}/edit-repo/blob/${sha}/index.js`); expect(await page.locator('a[href*="/edit/"]').count()).toBe(0); } finally { await page.close(); } }); test('Edit button does not appear for unauthenticated visitors', async () => { const ctx = await browser.newContext(); const page = await ctx.newPage(); try { await page.goto(`${BASE}/edit-repo/blob/main/index.js`); expect(await page.locator('a[href*="/edit/main/"]').count()).toBe(0); } finally { await page.close(); await ctx.close(); } }); test('edit page loads with file content pre-filled', async () => { const page = await adminCtx.newPage(); try { await page.goto(`${BASE}/edit-repo/edit/main/index.js`); expect(await page.locator('.file-blob-name').textContent()).toBe('index.js'); const content = await page.locator('textarea[name=content]').inputValue(); expect(content).toContain('hello'); const msg = await page.locator('textarea[name=message]').inputValue(); expect(msg).toBe('Edited index.js'); } finally { await page.close(); } }); test('edit page shows which branch will be committed to', async () => { const page = await adminCtx.newPage(); try { await page.goto(`${BASE}/edit-repo/edit/main/index.js`); expect(await page.content()).toContain('main'); } finally { await page.close(); } }); test('edit page returns 404 for non-branch ref', async () => { const sha = await getHeadCommit('edit-repo'); const page = await adminCtx.newPage(); try { const resp = await page.request.get(`${BASE}/edit-repo/edit/${sha}/index.js`); expect(resp.status()).toBe(404); } finally { await page.close(); } }); test('submitting edit creates a new commit and redirects to blob view', async () => { const page = await adminCtx.newPage(); try { await page.goto(`${BASE}/edit-repo/edit/main/index.js`); await page.fill('textarea[name=content]', 'console.log("edited");\n'); await page.fill('textarea[name=message]', 'Update index.js via web editor'); await page.locator('.form-actions button[type=submit]').click(); await page.waitForURL(/\/edit-repo\/commit\/[0-9a-f]{40}/); // The commit detail view should show the commit message expect(await page.content()).toContain('Update index.js via web editor'); } finally { await page.close(); } }); test('edit commit has a gpgsig header (is signed)', async () => { const repoDir = `${process.cwd()}/${DATA_DIR}/repos/edit-repo.git`; const hash = ( await $`git -C ${repoDir} log --format=%H --grep="Update index.js via web editor" -1`.quiet() ).text().trim(); expect(hash).toBeTruthy(); const obj = (await $`git -C ${repoDir} cat-file -p ${hash}`.quiet()).text(); expect(obj).toContain('gpgsig'); }); test('edit commit shows verified badge in commit log', async () => { const page = await adminCtx.newPage(); try { await page.goto(`${BASE}/edit-repo/commits/main`); const item = page.locator('.commit-item').filter({ hasText: 'Update index.js via web editor' }); expect(await item.locator('.sig-badge.verified').isVisible()).toBe(true); } finally { await page.close(); } }); test('GET edit page returns 404 for non-branch ref', async () => { const sha = await getHeadCommit('edit-repo'); const page = await adminCtx.newPage(); try { const resp = await page.request.get(`${BASE}/edit-repo/edit/${sha}/index.js`); expect(resp.status()).toBe(404); } finally { await page.close(); } }); });