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, writeTempFile, } from './helpers.ts'; let browser: Browser; let server: Awaited>; beforeAll(async () => { await setupTestEnv(); server = await spawnServer(); browser = await chromium.launch(); // Register alice (needed for user label management) 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(); } }); 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; } // ─── Labels ─────────────────────────────────────────────────────────────────── describe('labels', () => { let adminCtx: BrowserContext; let issueUrl: string; let patchUrl: string; 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 label-test.txt', '', '---', 'diff --git a/label-test.txt b/label-test.txt', 'new file mode 100644', 'index 0000000..9daeafb', '--- /dev/null', '+++ b/label-test.txt', '@@ -0,0 +1 @@', '+x', '', ].join('\n'); beforeAll(async () => { adminCtx = await loggedInContext(); // Create a dedicated repo for label tests const page = await adminCtx.newPage(); try { await page.goto(`${BASE}/new`); await page.fill('[name=name]', 'label-repo'); await page.click('form[action="/new"] button[type=submit]'); await page.waitForURL(`${BASE}/label-repo`); } finally { await page.close(); } // Create an issue const issuePage = await adminCtx.newPage(); try { await issuePage.goto(`${BASE}/label-repo/issues/new`); await issuePage.fill('[name=title]', 'Labelled issue'); await issuePage.click('form[action$="/issues"] button[type=submit]'); await issuePage.waitForURL(/\/label-repo\/issues\/\d+/); issueUrl = issuePage.url(); } finally { await issuePage.close(); } // Create a patch writeTempFile('/tmp/label-test.patch', VALID_PATCH); const patchPage = await adminCtx.newPage(); try { await patchPage.goto(`${BASE}/label-repo/patches/new`); await patchPage.fill('[name=title]', 'Labelled patch'); await patchPage.locator('[name=patch_file]').setInputFiles('/tmp/label-test.patch'); await patchPage.click('form[action$="/patches"] button[type=submit]'); await patchPage.waitForURL(/\/label-repo\/patches\/\d+/); patchUrl = patchPage.url(); } finally { await patchPage.close(); } }); afterAll(async () => { await adminCtx.close(); }); test('create label in repo settings', async () => { const page = await adminCtx.newPage(); try { await page.goto(`${BASE}/label-repo/settings`); await page.fill('input[name=name]', 'bug'); await page.locator('input[type=color][name=color]').evaluate( (el: any) => { el.value = '#ff0000'; }, ); await page.click('form[action$="/settings/labels"] button[type=submit]'); await page.waitForURL(/\/label-repo\/settings/); expect(await page.locator('.label-settings-name').allTextContents()).toContain('bug'); } finally { await page.close(); } }); test('create a second label', async () => { const page = await adminCtx.newPage(); try { await page.goto(`${BASE}/label-repo/settings`); await page.fill('input[name=name]', 'enhancement'); await page.locator('input[type=color][name=color]').evaluate( (el: any) => { el.value = '#00aa00'; }, ); await page.click('form[action$="/settings/labels"] button[type=submit]'); await page.waitForURL(/\/label-repo\/settings/); const badges = await page.locator('.label-settings-name').allTextContents(); expect(badges).toContain('bug'); expect(badges).toContain('enhancement'); } finally { await page.close(); } }); test('duplicate label name is rejected', async () => { const page = await adminCtx.newPage(); try { await page.goto(`${BASE}/label-repo/settings`); await page.fill('input[name=name]', 'bug'); await page.locator('input[type=color][name=color]').evaluate( (el: any) => { el.value = '#0000ff'; }, ); await page.click('form[action$="/settings/labels"] button[type=submit]'); await page.waitForURL(/\/label-repo\/settings/); expect(await page.locator('.form-error').isVisible()).toBe(true); } finally { await page.close(); } }); test('non-admin cannot create labels', async () => { const ctx = await browser.newContext(); try { const r = await ctx.request.post(`${BASE}/label-repo/settings/labels`, { form: { name: 'nope', color: '#123456' }, maxRedirects: 0, }); // Unauthenticated → redirected to /login expect(r.status()).toBe(302); expect(r.headers()['location']).toContain('/login'); } finally { await ctx.close(); } }); test('assign label to issue', async () => { const page = await adminCtx.newPage(); try { await page.goto(issueUrl); await page.selectOption('select[name=label_id]', { label: 'bug' }); await page.click('form[action$="/labels/add"] button[type=submit]'); await page.waitForURL(new RegExp(issueUrl.replace(BASE, ''))); expect(await page.locator('.label-badge').allTextContents()).toContain('bug'); } finally { await page.close(); } }); test('label appears on issue list', async () => { const page = await adminCtx.newPage(); try { await page.goto(`${BASE}/label-repo/issues`); const item = page.locator('.issue-item').filter({ hasText: 'Labelled issue' }); expect(await item.locator('.label-badge').allTextContents()).toContain('bug'); } finally { await page.close(); } }); test('filter issues by label shows only matching issues', async () => { // Create a second issue without the label const createPage = await adminCtx.newPage(); try { await createPage.goto(`${BASE}/label-repo/issues/new`); await createPage.fill('[name=title]', 'Unlabelled issue'); await createPage.click('form[action$="/issues"] button[type=submit]'); await createPage.waitForURL(/\/label-repo\/issues\/\d+/); } finally { await createPage.close(); } // Open filter popup and apply label filter const page = await adminCtx.newPage(); try { await page.goto(`${BASE}/label-repo/issues`); // Get the label id from the checkbox const checkbox = page.locator('.label-filter-item input[name=labels]').first(); const labelId = await checkbox.getAttribute('value'); expect(labelId).toBeTruthy(); // Navigate with the filter applied via URL await page.goto(`${BASE}/label-repo/issues?labels=${labelId}`); const titles = await page.locator('.issue-title').allTextContents(); expect(titles.some(t => t.includes('Labelled issue'))).toBe(true); expect(titles.some(t => t.includes('Unlabelled issue'))).toBe(false); } finally { await page.close(); } }); test('filter popup is visible without JS', async () => { // details/summary is a native HTML element — verify it renders const page = await adminCtx.newPage(); try { await page.goto(`${BASE}/label-repo/issues`); expect(await page.locator('details.label-filter').isVisible()).toBe(true); expect(await page.locator('details.label-filter summary').isVisible()).toBe(true); } finally { await page.close(); } }); test('remove label from issue', async () => { const page = await adminCtx.newPage(); try { await page.goto(issueUrl); await page.click('form[action$="/labels/remove"] button[type=submit]'); await page.waitForURL(new RegExp(issueUrl.replace(BASE, ''))); // Label badge should no longer appear in the labels row const labelBadges = await page.locator('.issue-labels-row .label-badge').allTextContents(); expect(labelBadges).not.toContain('bug'); } finally { await page.close(); } }); test('assign label to patch', async () => { const page = await adminCtx.newPage(); try { await page.goto(patchUrl); await page.selectOption('select[name=label_id]', { label: 'enhancement' }); await page.click('form[action$="/labels/add"] button[type=submit]'); await page.waitForURL(new RegExp(patchUrl.replace(BASE, ''))); expect(await page.locator('.label-badge').allTextContents()).toContain('enhancement'); } finally { await page.close(); } }); test('label appears on patch list', async () => { const page = await adminCtx.newPage(); try { await page.goto(`${BASE}/label-repo/patches`); const item = page.locator('.issue-item').filter({ hasText: 'Labelled patch' }); expect(await item.locator('.label-badge').allTextContents()).toContain('enhancement'); } finally { await page.close(); } }); test('filter patches by label', async () => { const page = await adminCtx.newPage(); try { await page.goto(`${BASE}/label-repo/patches`); // Find the checkbox for the 'enhancement' label specifically const checkbox = page.locator('.label-filter-item').filter({ hasText: 'enhancement' }) .locator('input[name=labels]'); const labelId = await checkbox.getAttribute('value'); expect(labelId).toBeTruthy(); await page.goto(`${BASE}/label-repo/patches?labels=${labelId}`); const titles = await page.locator('.issue-title').allTextContents(); expect(titles.some(t => t.includes('Labelled patch'))).toBe(true); } finally { await page.close(); } }); test('remove label from patch', async () => { const page = await adminCtx.newPage(); try { await page.goto(patchUrl); await page.click('form[action$="/labels/remove"] button[type=submit]'); await page.waitForURL(new RegExp(patchUrl.replace(BASE, ''))); const labelBadges = await page.locator('.issue-labels-row .label-badge').allTextContents(); expect(labelBadges).not.toContain('enhancement'); } finally { await page.close(); } }); test('delete label removes it from settings list', async () => { const page = await adminCtx.newPage(); try { await page.goto(`${BASE}/label-repo/settings`); const bugItem = page.locator('.label-settings-item').filter({ hasText: 'bug' }); await bugItem.locator('form[action$="/labels/delete"] button').click(); await page.waitForURL(/\/label-repo\/settings/); const badges = await page.locator('.label-settings-name').allTextContents(); expect(badges).not.toContain('bug'); } finally { await page.close(); } }); test('deleted label no longer appears in filter popup', async () => { const page = await adminCtx.newPage(); try { await page.goto(`${BASE}/label-repo/issues`); const filterLabels = await page.locator('.label-filter-item').allTextContents(); expect(filterLabels.every(t => !t.includes('bug'))).toBe(true); } finally { await page.close(); } }); }); // ─── User label management ──────────────────────────────────────────────────── describe('user label management', () => { let adminCtx: BrowserContext; let aliceCtx: BrowserContext; // URL of an issue owned by alice let aliceIssueUrl: string; // URL of an issue owned by admin let adminIssueUrl: string; // label IDs, fetched from the filter inputs let bugLabelId: string; 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 ulm-test.txt', '', '---', 'diff --git a/ulm-test.txt b/ulm-test.txt', 'new file mode 100644', 'index 0000000..9daeafb', '--- /dev/null', '+++ b/ulm-test.txt', '@@ -0,0 +1 @@', '+x', '', ].join('\n'); beforeAll(async () => { adminCtx = await loggedInContext(); aliceCtx = await loggedInContext('alice', 'password123'); // Create dedicated repo const repoPage = await adminCtx.newPage(); try { await repoPage.goto(`${BASE}/new`); await repoPage.fill('[name=name]', 'ulm-repo'); await repoPage.click('form[action="/new"] button[type=submit]'); await repoPage.waitForURL(`${BASE}/ulm-repo`); } finally { await repoPage.close(); } // Create labels 'bug' and 'feature' for (const name of ['bug', 'feature']) { const p = await adminCtx.newPage(); try { await p.goto(`${BASE}/ulm-repo/settings`); await p.fill('input[name=name]', name); await p.click('form[action$="/settings/labels"] button[type=submit]'); await p.waitForURL(/\/ulm-repo\/settings/); } finally { await p.close(); } } // Create an issue owned by admin const adminIssuePage = await adminCtx.newPage(); try { await adminIssuePage.goto(`${BASE}/ulm-repo/issues/new`); await adminIssuePage.fill('[name=title]', "Admin's issue"); await adminIssuePage.click('form[action$="/issues"] button[type=submit]'); await adminIssuePage.waitForURL(/\/ulm-repo\/issues\/\d+/); adminIssueUrl = adminIssuePage.url(); } finally { await adminIssuePage.close(); } // Create an issue owned by alice const aliceIssuePage = await aliceCtx.newPage(); try { await aliceIssuePage.goto(`${BASE}/ulm-repo/issues/new`); await aliceIssuePage.fill('[name=title]', "Alice's issue"); await aliceIssuePage.click('form[action$="/issues"] button[type=submit]'); await aliceIssuePage.waitForURL(/\/ulm-repo\/issues\/\d+/); aliceIssueUrl = aliceIssuePage.url(); } finally { await aliceIssuePage.close(); } // Grab the bug label id from the filter popup const filterPage = await adminCtx.newPage(); try { await filterPage.goto(`${BASE}/ulm-repo/issues`); const checkbox = filterPage.locator('.label-filter-item') .filter({ hasText: 'bug' }) .locator('input[name=labels]'); bugLabelId = (await checkbox.getAttribute('value')) ?? ''; } finally { await filterPage.close(); } }); afterAll(async () => { await adminCtx.close(); await aliceCtx.close(); }); // ── Settings ── test('allow_user_labels checkbox is present in repo settings', async () => { const page = await adminCtx.newPage(); try { await page.goto(`${BASE}/ulm-repo/settings`); expect(await page.locator('input[name=allow_user_labels]').count()).toBe(1); } finally { await page.close(); } }); test('allow_user_labels is off by default', async () => { const page = await adminCtx.newPage(); try { await page.goto(`${BASE}/ulm-repo/settings`); expect(await page.locator('input[name=allow_user_labels]').isChecked()).toBe(false); } finally { await page.close(); } }); // ── Label checkboxes hidden when setting is off ── test('label checkboxes not shown to non-admin on new issue form when allow_user_labels is off', async () => { const page = await aliceCtx.newPage(); try { await page.goto(`${BASE}/ulm-repo/issues/new`); expect(await page.locator('.label-checkbox-list').count()).toBe(0); } finally { await page.close(); } }); test('label checkboxes not shown to non-admin on new patch form when allow_user_labels is off', async () => { const page = await aliceCtx.newPage(); try { await page.goto(`${BASE}/ulm-repo/patches/new`); expect(await page.locator('.label-checkbox-list').count()).toBe(0); } finally { await page.close(); } }); test('label checkboxes shown to admin on new issue form regardless of setting', async () => { const page = await adminCtx.newPage(); try { await page.goto(`${BASE}/ulm-repo/issues/new`); expect(await page.locator('.label-checkbox-list').isVisible()).toBe(true); } finally { await page.close(); } }); // ── Enable the setting ── test('admin can enable allow_user_labels', async () => { const page = await adminCtx.newPage(); try { await page.goto(`${BASE}/ulm-repo/settings`); await page.check('input[name=allow_user_labels]'); await page.click('form[action$="/settings"] button[type=submit]'); await page.waitForURL(/\/ulm-repo\/settings/); // Verify it persisted await page.goto(`${BASE}/ulm-repo/settings`); expect(await page.locator('input[name=allow_user_labels]').isChecked()).toBe(true); } finally { await page.close(); } }); // ── Label checkboxes visible when setting is on ── test('label checkboxes shown to non-admin on new issue form when allow_user_labels is on', async () => { const page = await aliceCtx.newPage(); try { await page.goto(`${BASE}/ulm-repo/issues/new`); expect(await page.locator('.label-checkbox-list').isVisible()).toBe(true); const labels = await page.locator('.label-checkbox-list .label-badge').allTextContents(); expect(labels).toContain('bug'); expect(labels).toContain('feature'); } finally { await page.close(); } }); test('label checkboxes shown to non-admin on new patch form when allow_user_labels is on', async () => { const page = await aliceCtx.newPage(); try { await page.goto(`${BASE}/ulm-repo/patches/new`); expect(await page.locator('.label-checkbox-list').isVisible()).toBe(true); } finally { await page.close(); } }); // ── Creating with labels selected ── test('non-admin can create issue with label selected', async () => { const page = await aliceCtx.newPage(); try { await page.goto(`${BASE}/ulm-repo/issues/new`); await page.fill('[name=title]', 'Issue with label'); // Check the 'bug' label checkbox await page.locator('.label-checkbox-item').filter({ hasText: 'bug' }) .locator('input[type=checkbox]').check(); await page.click('form[action$="/issues"] button[type=submit]'); await page.waitForURL(/\/ulm-repo\/issues\/\d+/); const badges = await page.locator('.label-badge').allTextContents(); expect(badges).toContain('bug'); } finally { await page.close(); } }); test('non-admin can create patch with label selected', async () => { writeTempFile('/tmp/ulm-test.patch', VALID_PATCH); const page = await aliceCtx.newPage(); try { await page.goto(`${BASE}/ulm-repo/patches/new`); await page.fill('[name=title]', 'Patch with label'); await page.locator('[name=patch_file]').setInputFiles('/tmp/ulm-test.patch'); await page.locator('.label-checkbox-item').filter({ hasText: 'feature' }) .locator('input[type=checkbox]').check(); await page.click('form[action$="/patches"] button[type=submit]'); await page.waitForURL(/\/ulm-repo\/patches\/\d+/); const badges = await page.locator('.label-badge').allTextContents(); expect(badges).toContain('feature'); } finally { await page.close(); } }); test('label_ids in POST are ignored for non-admin when allow_user_labels is off (no label applied)', async () => { // Temporarily disable the setting, post with label_ids, re-enable await adminCtx.request.post(`${BASE}/ulm-repo/settings`, { form: { description: '', default_branch: 'main' }, // no allow_user_labels maxRedirects: 0, }).catch(() => {}); const resp = await aliceCtx.request.post(`${BASE}/ulm-repo/issues`, { form: { title: 'Issue sneaking labels', label_ids: bugLabelId }, maxRedirects: 0, }).catch(() => null); // Should redirect to the new issue const location = resp?.headers()['location'] ?? ''; const issueNum = location.split('/issues/')[1]; if (issueNum) { const page = await aliceCtx.newPage(); try { await page.goto(`${BASE}/ulm-repo/issues/${issueNum}`); const badges = await page.locator('.label-badge').allTextContents(); expect(badges).not.toContain('bug'); } finally { await page.close(); } } // Re-enable for subsequent tests await adminCtx.request.post(`${BASE}/ulm-repo/settings`, { form: { description: '', default_branch: 'main', allow_user_labels: '1' }, maxRedirects: 0, }).catch(() => {}); }); // ── Add/remove labels on existing items ── test('non-admin can add label to their own issue', async () => { const issueNum = aliceIssueUrl.split('/issues/')[1]; const page = await aliceCtx.newPage(); try { await page.goto(aliceIssueUrl); await page.selectOption('select[name=label_id]', { label: 'bug' }); await page.click('form[action$="/labels/add"] button[type=submit]'); await page.waitForURL(new RegExp(`/ulm-repo/issues/${issueNum}`)); expect(await page.locator('.label-badge').allTextContents()).toContain('bug'); } finally { await page.close(); } }); test('non-admin can remove label from their own issue', async () => { const issueNum = aliceIssueUrl.split('/issues/')[1]; const page = await aliceCtx.newPage(); try { await page.goto(aliceIssueUrl); await page.click('form[action$="/labels/remove"] button[type=submit]'); await page.waitForURL(new RegExp(`/ulm-repo/issues/${issueNum}`)); const badges = await page.locator('.issue-labels-row .label-badge').allTextContents(); expect(badges).not.toContain('bug'); } finally { await page.close(); } }); test('non-admin cannot add label to another user\'s issue', async () => { const issueNum = adminIssueUrl.split('/issues/')[1]; const resp = await aliceCtx.request.post( `${BASE}/ulm-repo/issues/${issueNum}/labels/add`, { form: { label_id: bugLabelId }, maxRedirects: 0 }, ); expect(resp.status()).toBe(403); }); test('unauthenticated user gets 401 adding a label', async () => { const issueNum = aliceIssueUrl.split('/issues/')[1]; const ctx = await browser.newContext(); try { const resp = await ctx.request.post( `${BASE}/ulm-repo/issues/${issueNum}/labels/add`, { form: { label_id: bugLabelId }, maxRedirects: 0 }, ); expect(resp.status()).toBe(401); } finally { await ctx.close(); } }); // ── Disable setting and verify enforcement ── test('admin can disable allow_user_labels', async () => { const page = await adminCtx.newPage(); try { await page.goto(`${BASE}/ulm-repo/settings`); await page.uncheck('input[name=allow_user_labels]'); await page.click('form[action$="/settings"] button[type=submit]'); await page.waitForURL(/\/ulm-repo\/settings/); await page.goto(`${BASE}/ulm-repo/settings`); expect(await page.locator('input[name=allow_user_labels]').isChecked()).toBe(false); } finally { await page.close(); } }); test('non-admin gets 403 adding label to own issue when allow_user_labels is off', async () => { const issueNum = aliceIssueUrl.split('/issues/')[1]; const resp = await aliceCtx.request.post( `${BASE}/ulm-repo/issues/${issueNum}/labels/add`, { form: { label_id: bugLabelId }, maxRedirects: 0 }, ); expect(resp.status()).toBe(403); }); test('label checkboxes hidden on new issue form after allow_user_labels disabled', async () => { const page = await aliceCtx.newPage(); try { await page.goto(`${BASE}/ulm-repo/issues/new`); expect(await page.locator('.label-checkbox-list').count()).toBe(0); } finally { await page.close(); } }); });