import { describe, test, expect, beforeAll, afterAll } from 'bun:test'; import { chromium } from 'playwright'; import type { Browser, BrowserContext } from 'playwright'; import { BASE, ADMIN_PASS, setupTestEnv, spawnServer, killServer, login, seedRepo, } 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; } // ─── 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(); } }); }); // ─── 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('.title-edit-open'); 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('.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('cannot edit comment via wrong repo url (cross-repo bypass)', async () => { // Create a second repo const setupPage = await adminCtx.newPage(); try { await setupPage.goto(`${BASE}/new`); await setupPage.fill('[name=name]', 'other-repo'); await setupPage.click('form[action="/new"] button[type=submit]'); await setupPage.waitForURL(`${BASE}/other-repo`); } finally { await setupPage.close(); } // Pull a comment id from the existing my-repo issue const issueNum = issueUrl.split('/issues/')[1]; const page = await adminCtx.newPage(); try { await page.goto(issueUrl); const formAction = await page .locator(`form[action*="/my-repo/issues/${issueNum}/comments/"][action$="/edit"]`) .first() .getAttribute('action'); expect(formAction).toBeTruthy(); const commentId = formAction!.split('/comments/')[1]!.split('/')[0]; // Edit the same comment via /other-repo/... — must 404, not 200/302 const resp = await page.request.post( `${BASE}/other-repo/issues/${issueNum}/comments/${commentId}/edit`, { form: { edit_body: 'cross-repo bypass attempt' }, maxRedirects: 0 }, ); expect(resp.status()).toBe(404); // And the original comment must be unchanged await page.goto(issueUrl); const bodies = await page.locator('.timeline-body').allTextContents(); expect(bodies.every(b => !b.includes('cross-repo bypass attempt'))).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(); } }); });