import { describe, test, expect, beforeAll, afterAll } from 'bun:test'; import { chromium } from 'playwright'; import type { Browser, BrowserContext } from 'playwright'; import { existsSync, readFileSync } from 'node:fs'; import { BASE, DATA_DIR, ADMIN_PASS, setupTestEnv, spawnServer, killServer, login, seedRepo, writeTempFile, gitOutput, } from './helpers.ts'; let browser: Browser; let server: Awaited>; beforeAll(async () => { await setupTestEnv(); server = await spawnServer(); browser = await chromium.launch(); // Register alice const regCtx = await browser.newContext(); const regPage = await regCtx.newPage(); try { await regPage.goto(`${BASE}/register`); await regPage.fill('[name=username]', 'alice'); await regPage.fill('[name=password]', 'password123'); await regPage.fill('[name=password2]', 'password123'); await regPage.click('button[type=submit]'); await regPage.waitForURL(BASE + '/'); } finally { await regCtx.close(); } // Create my-repo const adminCtx = await browser.newContext(); const adminPage = await adminCtx.newPage(); try { await login(adminPage); await adminPage.goto(`${BASE}/new`); await adminPage.fill('[name=name]', 'my-repo'); await adminPage.click('form[action="/new"] button[type=submit]'); await adminPage.waitForURL(`${BASE}/my-repo`); } finally { await adminCtx.close(); } await seedRepo('my-repo'); }); afterAll(async () => { await browser.close(); await killServer(server); }); 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; } // ─── 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('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_DIR}/repos/my-repo.git`; const authorName = gitOutput(['log', '-1', '--format=%aN'], repoPath); const authorEmail = gitOutput(['log', '-1', '--format=%aE'], repoPath); const subject = gitOutput(['log', '-1', '--format=%s'], repoPath); 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, ''))); } 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_DIR}/repos/my-repo.git`; const authorName = gitOutput(['log', '-1', '--format=%aN'], repoPath); 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); }); }); // ─── 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 = gitOutput(['log', '--format=%H', '--grep=Add patch-test.txt', '-1'], repoDir); expect(hash).toBeTruthy(); const obj = gitOutput(['cat-file', '-p', hash], repoDir); 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 = gitOutput(['log', '--format=%H', '--grep=Initial commit', '-1'], repoDir); expect(hash).toBeTruthy(); const obj = gitOutput(['cat-file', '-p', hash], repoDir); 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 = gitOutput(['log', '--format=%H', '--grep=Add patch-test.txt', '-1'], repoDir); 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 = gitOutput(['log', '--format=%H', '--grep=Initial commit', '-1'], repoDir); 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(); } }); });