e2e.labels.test.ts
| 1 | import { describe, test, expect, beforeAll, afterAll } from 'bun:test'; |
| 2 | import { chromium } from 'playwright'; |
| 3 | import type { Browser, BrowserContext } from 'playwright'; |
| 4 | import { |
| 5 | BASE, |
| 6 | ADMIN_PASS, |
| 7 | setupTestEnv, |
| 8 | spawnServer, |
| 9 | killServer, |
| 10 | login, |
| 11 | writeTempFile, |
| 12 | } from './helpers.ts'; |
| 13 | |
| 14 | let browser: Browser; |
| 15 | let server: Awaited<ReturnType<typeof spawnServer>>; |
| 16 | |
| 17 | beforeAll(async () => { |
| 18 | await setupTestEnv(); |
| 19 | server = await spawnServer(); |
| 20 | browser = await chromium.launch(); |
| 21 | |
| 22 | // Register alice (needed for user label management) |
| 23 | const regCtx = await browser.newContext(); |
| 24 | const regPage = await regCtx.newPage(); |
| 25 | try { |
| 26 | await regPage.goto(`${BASE}/register`); |
| 27 | await regPage.fill('[name=username]', 'alice'); |
| 28 | await regPage.fill('[name=password]', 'password123'); |
| 29 | await regPage.fill('[name=password2]', 'password123'); |
| 30 | await regPage.click('button[type=submit]'); |
| 31 | await regPage.waitForURL(BASE + '/'); |
| 32 | } finally { await regCtx.close(); } |
| 33 | }); |
| 34 | |
| 35 | afterAll(async () => { |
| 36 | await browser.close(); |
| 37 | await killServer(server); |
| 38 | }); |
| 39 | |
| 40 | async function loggedInContext(username = 'admin', password = ADMIN_PASS) { |
| 41 | const ctx = await browser.newContext(); |
| 42 | const page = await ctx.newPage(); |
| 43 | await login(page, username, password); |
| 44 | await page.close(); |
| 45 | return ctx; |
| 46 | } |
| 47 | |
| 48 | // ─── Labels ─────────────────────────────────────────────────────────────────── |
| 49 | |
| 50 | describe('labels', () => { |
| 51 | let adminCtx: BrowserContext; |
| 52 | let issueUrl: string; |
| 53 | let patchUrl: string; |
| 54 | |
| 55 | const VALID_PATCH = [ |
| 56 | 'From a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2 Mon Sep 17 00:00:00 2001', |
| 57 | 'From: Test User <test@example.com>', |
| 58 | 'Date: Mon, 01 Jan 2024 12:00:00 +0000', |
| 59 | 'Subject: [PATCH] Add label-test.txt', |
| 60 | '', |
| 61 | '---', |
| 62 | 'diff --git a/label-test.txt b/label-test.txt', |
| 63 | 'new file mode 100644', |
| 64 | 'index 0000000..9daeafb', |
| 65 | '--- /dev/null', |
| 66 | '+++ b/label-test.txt', |
| 67 | '@@ -0,0 +1 @@', |
| 68 | '+x', |
| 69 | '', |
| 70 | ].join('\n'); |
| 71 | |
| 72 | beforeAll(async () => { |
| 73 | adminCtx = await loggedInContext(); |
| 74 | |
| 75 | // Create a dedicated repo for label tests |
| 76 | const page = await adminCtx.newPage(); |
| 77 | try { |
| 78 | await page.goto(`${BASE}/new`); |
| 79 | await page.fill('[name=name]', 'label-repo'); |
| 80 | await page.click('form[action="/new"] button[type=submit]'); |
| 81 | await page.waitForURL(`${BASE}/label-repo`); |
| 82 | } finally { await page.close(); } |
| 83 | |
| 84 | // Create an issue |
| 85 | const issuePage = await adminCtx.newPage(); |
| 86 | try { |
| 87 | await issuePage.goto(`${BASE}/label-repo/issues/new`); |
| 88 | await issuePage.fill('[name=title]', 'Labelled issue'); |
| 89 | await issuePage.click('form[action$="/issues"] button[type=submit]'); |
| 90 | await issuePage.waitForURL(/\/label-repo\/issues\/\d+/); |
| 91 | issueUrl = issuePage.url(); |
| 92 | } finally { await issuePage.close(); } |
| 93 | |
| 94 | // Create a patch |
| 95 | writeTempFile('/tmp/label-test.patch', VALID_PATCH); |
| 96 | const patchPage = await adminCtx.newPage(); |
| 97 | try { |
| 98 | await patchPage.goto(`${BASE}/label-repo/patches/new`); |
| 99 | await patchPage.fill('[name=title]', 'Labelled patch'); |
| 100 | await patchPage.locator('[name=patch_file]').setInputFiles('/tmp/label-test.patch'); |
| 101 | await patchPage.click('form[action$="/patches"] button[type=submit]'); |
| 102 | await patchPage.waitForURL(/\/label-repo\/patches\/\d+/); |
| 103 | patchUrl = patchPage.url(); |
| 104 | } finally { await patchPage.close(); } |
| 105 | }); |
| 106 | |
| 107 | afterAll(async () => { await adminCtx.close(); }); |
| 108 | |
| 109 | test('create label in repo settings', async () => { |
| 110 | const page = await adminCtx.newPage(); |
| 111 | try { |
| 112 | await page.goto(`${BASE}/label-repo/settings`); |
| 113 | await page.fill('input[name=name]', 'bug'); |
| 114 | await page.locator('input[type=color][name=color]').evaluate( |
| 115 | (el: any) => { el.value = '#ff0000'; }, |
| 116 | ); |
| 117 | await page.click('form[action$="/settings/labels"] button[type=submit]'); |
| 118 | await page.waitForURL(/\/label-repo\/settings/); |
| 119 | expect(await page.locator('.label-settings-name').allTextContents()).toContain('bug'); |
| 120 | } finally { await page.close(); } |
| 121 | }); |
| 122 | |
| 123 | test('create a second label', async () => { |
| 124 | const page = await adminCtx.newPage(); |
| 125 | try { |
| 126 | await page.goto(`${BASE}/label-repo/settings`); |
| 127 | await page.fill('input[name=name]', 'enhancement'); |
| 128 | await page.locator('input[type=color][name=color]').evaluate( |
| 129 | (el: any) => { el.value = '#00aa00'; }, |
| 130 | ); |
| 131 | await page.click('form[action$="/settings/labels"] button[type=submit]'); |
| 132 | await page.waitForURL(/\/label-repo\/settings/); |
| 133 | const badges = await page.locator('.label-settings-name').allTextContents(); |
| 134 | expect(badges).toContain('bug'); |
| 135 | expect(badges).toContain('enhancement'); |
| 136 | } finally { await page.close(); } |
| 137 | }); |
| 138 | |
| 139 | test('duplicate label name is rejected', async () => { |
| 140 | const page = await adminCtx.newPage(); |
| 141 | try { |
| 142 | await page.goto(`${BASE}/label-repo/settings`); |
| 143 | await page.fill('input[name=name]', 'bug'); |
| 144 | await page.locator('input[type=color][name=color]').evaluate( |
| 145 | (el: any) => { el.value = '#0000ff'; }, |
| 146 | ); |
| 147 | await page.click('form[action$="/settings/labels"] button[type=submit]'); |
| 148 | await page.waitForURL(/\/label-repo\/settings/); |
| 149 | expect(await page.locator('.form-error').isVisible()).toBe(true); |
| 150 | } finally { await page.close(); } |
| 151 | }); |
| 152 | |
| 153 | test('non-admin cannot create labels', async () => { |
| 154 | const ctx = await browser.newContext(); |
| 155 | try { |
| 156 | const r = await ctx.request.post(`${BASE}/label-repo/settings/labels`, { |
| 157 | form: { name: 'nope', color: '#123456' }, |
| 158 | maxRedirects: 0, |
| 159 | }); |
| 160 | // Unauthenticated → redirected to /login |
| 161 | expect(r.status()).toBe(302); |
| 162 | expect(r.headers()['location']).toContain('/login'); |
| 163 | } finally { await ctx.close(); } |
| 164 | }); |
| 165 | |
| 166 | test('assign label to issue', async () => { |
| 167 | const page = await adminCtx.newPage(); |
| 168 | try { |
| 169 | await page.goto(issueUrl); |
| 170 | await page.selectOption('select[name=label_id]', { label: 'bug' }); |
| 171 | await page.click('form[action$="/labels/add"] button[type=submit]'); |
| 172 | await page.waitForURL(new RegExp(issueUrl.replace(BASE, ''))); |
| 173 | expect(await page.locator('.label-badge').allTextContents()).toContain('bug'); |
| 174 | } finally { await page.close(); } |
| 175 | }); |
| 176 | |
| 177 | test('label appears on issue list', async () => { |
| 178 | const page = await adminCtx.newPage(); |
| 179 | try { |
| 180 | await page.goto(`${BASE}/label-repo/issues`); |
| 181 | const item = page.locator('.issue-item').filter({ hasText: 'Labelled issue' }); |
| 182 | expect(await item.locator('.label-badge').allTextContents()).toContain('bug'); |
| 183 | } finally { await page.close(); } |
| 184 | }); |
| 185 | |
| 186 | test('filter issues by label shows only matching issues', async () => { |
| 187 | // Create a second issue without the label |
| 188 | const createPage = await adminCtx.newPage(); |
| 189 | try { |
| 190 | await createPage.goto(`${BASE}/label-repo/issues/new`); |
| 191 | await createPage.fill('[name=title]', 'Unlabelled issue'); |
| 192 | await createPage.click('form[action$="/issues"] button[type=submit]'); |
| 193 | await createPage.waitForURL(/\/label-repo\/issues\/\d+/); |
| 194 | } finally { await createPage.close(); } |
| 195 | |
| 196 | // Open filter popup and apply label filter |
| 197 | const page = await adminCtx.newPage(); |
| 198 | try { |
| 199 | await page.goto(`${BASE}/label-repo/issues`); |
| 200 | // Get the label id from the checkbox |
| 201 | const checkbox = page.locator('.label-filter-item input[name=labels]').first(); |
| 202 | const labelId = await checkbox.getAttribute('value'); |
| 203 | expect(labelId).toBeTruthy(); |
| 204 | |
| 205 | // Navigate with the filter applied via URL |
| 206 | await page.goto(`${BASE}/label-repo/issues?labels=${labelId}`); |
| 207 | const titles = await page.locator('.issue-title').allTextContents(); |
| 208 | expect(titles.some(t => t.includes('Labelled issue'))).toBe(true); |
| 209 | expect(titles.some(t => t.includes('Unlabelled issue'))).toBe(false); |
| 210 | } finally { await page.close(); } |
| 211 | }); |
| 212 | |
| 213 | test('filter popup is visible without JS', async () => { |
| 214 | // details/summary is a native HTML element — verify it renders |
| 215 | const page = await adminCtx.newPage(); |
| 216 | try { |
| 217 | await page.goto(`${BASE}/label-repo/issues`); |
| 218 | expect(await page.locator('details.label-filter').isVisible()).toBe(true); |
| 219 | expect(await page.locator('details.label-filter summary').isVisible()).toBe(true); |
| 220 | } finally { await page.close(); } |
| 221 | }); |
| 222 | |
| 223 | test('remove label from issue', async () => { |
| 224 | const page = await adminCtx.newPage(); |
| 225 | try { |
| 226 | await page.goto(issueUrl); |
| 227 | await page.click('form[action$="/labels/remove"] button[type=submit]'); |
| 228 | await page.waitForURL(new RegExp(issueUrl.replace(BASE, ''))); |
| 229 | // Label badge should no longer appear in the labels row |
| 230 | const labelBadges = await page.locator('.issue-labels-row .label-badge').allTextContents(); |
| 231 | expect(labelBadges).not.toContain('bug'); |
| 232 | } finally { await page.close(); } |
| 233 | }); |
| 234 | |
| 235 | test('assign label to patch', async () => { |
| 236 | const page = await adminCtx.newPage(); |
| 237 | try { |
| 238 | await page.goto(patchUrl); |
| 239 | await page.selectOption('select[name=label_id]', { label: 'enhancement' }); |
| 240 | await page.click('form[action$="/labels/add"] button[type=submit]'); |
| 241 | await page.waitForURL(new RegExp(patchUrl.replace(BASE, ''))); |
| 242 | expect(await page.locator('.label-badge').allTextContents()).toContain('enhancement'); |
| 243 | } finally { await page.close(); } |
| 244 | }); |
| 245 | |
| 246 | test('label appears on patch list', async () => { |
| 247 | const page = await adminCtx.newPage(); |
| 248 | try { |
| 249 | await page.goto(`${BASE}/label-repo/patches`); |
| 250 | const item = page.locator('.issue-item').filter({ hasText: 'Labelled patch' }); |
| 251 | expect(await item.locator('.label-badge').allTextContents()).toContain('enhancement'); |
| 252 | } finally { await page.close(); } |
| 253 | }); |
| 254 | |
| 255 | test('filter patches by label', async () => { |
| 256 | const page = await adminCtx.newPage(); |
| 257 | try { |
| 258 | await page.goto(`${BASE}/label-repo/patches`); |
| 259 | // Find the checkbox for the 'enhancement' label specifically |
| 260 | const checkbox = page.locator('.label-filter-item').filter({ hasText: 'enhancement' }) |
| 261 | .locator('input[name=labels]'); |
| 262 | const labelId = await checkbox.getAttribute('value'); |
| 263 | expect(labelId).toBeTruthy(); |
| 264 | |
| 265 | await page.goto(`${BASE}/label-repo/patches?labels=${labelId}`); |
| 266 | const titles = await page.locator('.issue-title').allTextContents(); |
| 267 | expect(titles.some(t => t.includes('Labelled patch'))).toBe(true); |
| 268 | } finally { await page.close(); } |
| 269 | }); |
| 270 | |
| 271 | test('remove label from patch', async () => { |
| 272 | const page = await adminCtx.newPage(); |
| 273 | try { |
| 274 | await page.goto(patchUrl); |
| 275 | await page.click('form[action$="/labels/remove"] button[type=submit]'); |
| 276 | await page.waitForURL(new RegExp(patchUrl.replace(BASE, ''))); |
| 277 | const labelBadges = await page.locator('.issue-labels-row .label-badge').allTextContents(); |
| 278 | expect(labelBadges).not.toContain('enhancement'); |
| 279 | } finally { await page.close(); } |
| 280 | }); |
| 281 | |
| 282 | test('delete label removes it from settings list', async () => { |
| 283 | const page = await adminCtx.newPage(); |
| 284 | try { |
| 285 | await page.goto(`${BASE}/label-repo/settings`); |
| 286 | const bugItem = page.locator('.label-settings-item').filter({ hasText: 'bug' }); |
| 287 | await bugItem.locator('form[action$="/labels/delete"] button').click(); |
| 288 | await page.waitForURL(/\/label-repo\/settings/); |
| 289 | const badges = await page.locator('.label-settings-name').allTextContents(); |
| 290 | expect(badges).not.toContain('bug'); |
| 291 | } finally { await page.close(); } |
| 292 | }); |
| 293 | |
| 294 | test('deleted label no longer appears in filter popup', async () => { |
| 295 | const page = await adminCtx.newPage(); |
| 296 | try { |
| 297 | await page.goto(`${BASE}/label-repo/issues`); |
| 298 | const filterLabels = await page.locator('.label-filter-item').allTextContents(); |
| 299 | expect(filterLabels.every(t => !t.includes('bug'))).toBe(true); |
| 300 | } finally { await page.close(); } |
| 301 | }); |
| 302 | }); |
| 303 | |
| 304 | // ─── User label management ──────────────────────────────────────────────────── |
| 305 | |
| 306 | describe('user label management', () => { |
| 307 | let adminCtx: BrowserContext; |
| 308 | let aliceCtx: BrowserContext; |
| 309 | // URL of an issue owned by alice |
| 310 | let aliceIssueUrl: string; |
| 311 | // URL of an issue owned by admin |
| 312 | let adminIssueUrl: string; |
| 313 | // label IDs, fetched from the filter inputs |
| 314 | let bugLabelId: string; |
| 315 | |
| 316 | const VALID_PATCH = [ |
| 317 | 'From a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2 Mon Sep 17 00:00:00 2001', |
| 318 | 'From: Test User <test@example.com>', |
| 319 | 'Date: Mon, 01 Jan 2024 12:00:00 +0000', |
| 320 | 'Subject: [PATCH] Add ulm-test.txt', |
| 321 | '', |
| 322 | '---', |
| 323 | 'diff --git a/ulm-test.txt b/ulm-test.txt', |
| 324 | 'new file mode 100644', |
| 325 | 'index 0000000..9daeafb', |
| 326 | '--- /dev/null', |
| 327 | '+++ b/ulm-test.txt', |
| 328 | '@@ -0,0 +1 @@', |
| 329 | '+x', |
| 330 | '', |
| 331 | ].join('\n'); |
| 332 | |
| 333 | beforeAll(async () => { |
| 334 | adminCtx = await loggedInContext(); |
| 335 | aliceCtx = await loggedInContext('alice', 'password123'); |
| 336 | |
| 337 | // Create dedicated repo |
| 338 | const repoPage = await adminCtx.newPage(); |
| 339 | try { |
| 340 | await repoPage.goto(`${BASE}/new`); |
| 341 | await repoPage.fill('[name=name]', 'ulm-repo'); |
| 342 | await repoPage.click('form[action="/new"] button[type=submit]'); |
| 343 | await repoPage.waitForURL(`${BASE}/ulm-repo`); |
| 344 | } finally { await repoPage.close(); } |
| 345 | |
| 346 | // Create labels 'bug' and 'feature' |
| 347 | for (const name of ['bug', 'feature']) { |
| 348 | const p = await adminCtx.newPage(); |
| 349 | try { |
| 350 | await p.goto(`${BASE}/ulm-repo/settings`); |
| 351 | await p.fill('input[name=name]', name); |
| 352 | await p.click('form[action$="/settings/labels"] button[type=submit]'); |
| 353 | await p.waitForURL(/\/ulm-repo\/settings/); |
| 354 | } finally { await p.close(); } |
| 355 | } |
| 356 | |
| 357 | // Create an issue owned by admin |
| 358 | const adminIssuePage = await adminCtx.newPage(); |
| 359 | try { |
| 360 | await adminIssuePage.goto(`${BASE}/ulm-repo/issues/new`); |
| 361 | await adminIssuePage.fill('[name=title]', "Admin's issue"); |
| 362 | await adminIssuePage.click('form[action$="/issues"] button[type=submit]'); |
| 363 | await adminIssuePage.waitForURL(/\/ulm-repo\/issues\/\d+/); |
| 364 | adminIssueUrl = adminIssuePage.url(); |
| 365 | } finally { await adminIssuePage.close(); } |
| 366 | |
| 367 | // Create an issue owned by alice |
| 368 | const aliceIssuePage = await aliceCtx.newPage(); |
| 369 | try { |
| 370 | await aliceIssuePage.goto(`${BASE}/ulm-repo/issues/new`); |
| 371 | await aliceIssuePage.fill('[name=title]', "Alice's issue"); |
| 372 | await aliceIssuePage.click('form[action$="/issues"] button[type=submit]'); |
| 373 | await aliceIssuePage.waitForURL(/\/ulm-repo\/issues\/\d+/); |
| 374 | aliceIssueUrl = aliceIssuePage.url(); |
| 375 | } finally { await aliceIssuePage.close(); } |
| 376 | |
| 377 | // Grab the bug label id from the filter popup |
| 378 | const filterPage = await adminCtx.newPage(); |
| 379 | try { |
| 380 | await filterPage.goto(`${BASE}/ulm-repo/issues`); |
| 381 | const checkbox = filterPage.locator('.label-filter-item') |
| 382 | .filter({ hasText: 'bug' }) |
| 383 | .locator('input[name=labels]'); |
| 384 | bugLabelId = (await checkbox.getAttribute('value')) ?? ''; |
| 385 | } finally { await filterPage.close(); } |
| 386 | }); |
| 387 | |
| 388 | afterAll(async () => { |
| 389 | await adminCtx.close(); |
| 390 | await aliceCtx.close(); |
| 391 | }); |
| 392 | |
| 393 | // ── Settings ── |
| 394 | |
| 395 | test('allow_user_labels checkbox is present in repo settings', async () => { |
| 396 | const page = await adminCtx.newPage(); |
| 397 | try { |
| 398 | await page.goto(`${BASE}/ulm-repo/settings`); |
| 399 | expect(await page.locator('input[name=allow_user_labels]').count()).toBe(1); |
| 400 | } finally { await page.close(); } |
| 401 | }); |
| 402 | |
| 403 | test('allow_user_labels is off by default', async () => { |
| 404 | const page = await adminCtx.newPage(); |
| 405 | try { |
| 406 | await page.goto(`${BASE}/ulm-repo/settings`); |
| 407 | expect(await page.locator('input[name=allow_user_labels]').isChecked()).toBe(false); |
| 408 | } finally { await page.close(); } |
| 409 | }); |
| 410 | |
| 411 | // ── Label checkboxes hidden when setting is off ── |
| 412 | |
| 413 | test('label checkboxes not shown to non-admin on new issue form when allow_user_labels is off', async () => { |
| 414 | const page = await aliceCtx.newPage(); |
| 415 | try { |
| 416 | await page.goto(`${BASE}/ulm-repo/issues/new`); |
| 417 | expect(await page.locator('.label-checkbox-list').count()).toBe(0); |
| 418 | } finally { await page.close(); } |
| 419 | }); |
| 420 | |
| 421 | test('label checkboxes not shown to non-admin on new patch form when allow_user_labels is off', async () => { |
| 422 | const page = await aliceCtx.newPage(); |
| 423 | try { |
| 424 | await page.goto(`${BASE}/ulm-repo/patches/new`); |
| 425 | expect(await page.locator('.label-checkbox-list').count()).toBe(0); |
| 426 | } finally { await page.close(); } |
| 427 | }); |
| 428 | |
| 429 | test('label checkboxes shown to admin on new issue form regardless of setting', async () => { |
| 430 | const page = await adminCtx.newPage(); |
| 431 | try { |
| 432 | await page.goto(`${BASE}/ulm-repo/issues/new`); |
| 433 | expect(await page.locator('.label-checkbox-list').isVisible()).toBe(true); |
| 434 | } finally { await page.close(); } |
| 435 | }); |
| 436 | |
| 437 | // ── Enable the setting ── |
| 438 | |
| 439 | test('admin can enable allow_user_labels', async () => { |
| 440 | const page = await adminCtx.newPage(); |
| 441 | try { |
| 442 | await page.goto(`${BASE}/ulm-repo/settings`); |
| 443 | await page.check('input[name=allow_user_labels]'); |
| 444 | await page.click('form[action$="/settings"] button[type=submit]'); |
| 445 | await page.waitForURL(/\/ulm-repo\/settings/); |
| 446 | // Verify it persisted |
| 447 | await page.goto(`${BASE}/ulm-repo/settings`); |
| 448 | expect(await page.locator('input[name=allow_user_labels]').isChecked()).toBe(true); |
| 449 | } finally { await page.close(); } |
| 450 | }); |
| 451 | |
| 452 | // ── Label checkboxes visible when setting is on ── |
| 453 | |
| 454 | test('label checkboxes shown to non-admin on new issue form when allow_user_labels is on', async () => { |
| 455 | const page = await aliceCtx.newPage(); |
| 456 | try { |
| 457 | await page.goto(`${BASE}/ulm-repo/issues/new`); |
| 458 | expect(await page.locator('.label-checkbox-list').isVisible()).toBe(true); |
| 459 | const labels = await page.locator('.label-checkbox-list .label-badge').allTextContents(); |
| 460 | expect(labels).toContain('bug'); |
| 461 | expect(labels).toContain('feature'); |
| 462 | } finally { await page.close(); } |
| 463 | }); |
| 464 | |
| 465 | test('label checkboxes shown to non-admin on new patch form when allow_user_labels is on', async () => { |
| 466 | const page = await aliceCtx.newPage(); |
| 467 | try { |
| 468 | await page.goto(`${BASE}/ulm-repo/patches/new`); |
| 469 | expect(await page.locator('.label-checkbox-list').isVisible()).toBe(true); |
| 470 | } finally { await page.close(); } |
| 471 | }); |
| 472 | |
| 473 | // ── Creating with labels selected ── |
| 474 | |
| 475 | test('non-admin can create issue with label selected', async () => { |
| 476 | const page = await aliceCtx.newPage(); |
| 477 | try { |
| 478 | await page.goto(`${BASE}/ulm-repo/issues/new`); |
| 479 | await page.fill('[name=title]', 'Issue with label'); |
| 480 | // Check the 'bug' label checkbox |
| 481 | await page.locator('.label-checkbox-item').filter({ hasText: 'bug' }) |
| 482 | .locator('input[type=checkbox]').check(); |
| 483 | await page.click('form[action$="/issues"] button[type=submit]'); |
| 484 | await page.waitForURL(/\/ulm-repo\/issues\/\d+/); |
| 485 | const badges = await page.locator('.label-badge').allTextContents(); |
| 486 | expect(badges).toContain('bug'); |
| 487 | } finally { await page.close(); } |
| 488 | }); |
| 489 | |
| 490 | test('non-admin can create patch with label selected', async () => { |
| 491 | writeTempFile('/tmp/ulm-test.patch', VALID_PATCH); |
| 492 | const page = await aliceCtx.newPage(); |
| 493 | try { |
| 494 | await page.goto(`${BASE}/ulm-repo/patches/new`); |
| 495 | await page.fill('[name=title]', 'Patch with label'); |
| 496 | await page.locator('[name=patch_file]').setInputFiles('/tmp/ulm-test.patch'); |
| 497 | await page.locator('.label-checkbox-item').filter({ hasText: 'feature' }) |
| 498 | .locator('input[type=checkbox]').check(); |
| 499 | await page.click('form[action$="/patches"] button[type=submit]'); |
| 500 | await page.waitForURL(/\/ulm-repo\/patches\/\d+/); |
| 501 | const badges = await page.locator('.label-badge').allTextContents(); |
| 502 | expect(badges).toContain('feature'); |
| 503 | } finally { await page.close(); } |
| 504 | }); |
| 505 | |
| 506 | test('label_ids in POST are ignored for non-admin when allow_user_labels is off (no label applied)', async () => { |
| 507 | // Temporarily disable the setting, post with label_ids, re-enable |
| 508 | await adminCtx.request.post(`${BASE}/ulm-repo/settings`, { |
| 509 | form: { description: '', default_branch: 'main' }, // no allow_user_labels |
| 510 | maxRedirects: 0, |
| 511 | }).catch(() => {}); |
| 512 | |
| 513 | const resp = await aliceCtx.request.post(`${BASE}/ulm-repo/issues`, { |
| 514 | form: { title: 'Issue sneaking labels', label_ids: bugLabelId }, |
| 515 | maxRedirects: 0, |
| 516 | }).catch(() => null); |
| 517 | // Should redirect to the new issue |
| 518 | const location = resp?.headers()['location'] ?? ''; |
| 519 | const issueNum = location.split('/issues/')[1]; |
| 520 | |
| 521 | if (issueNum) { |
| 522 | const page = await aliceCtx.newPage(); |
| 523 | try { |
| 524 | await page.goto(`${BASE}/ulm-repo/issues/${issueNum}`); |
| 525 | const badges = await page.locator('.label-badge').allTextContents(); |
| 526 | expect(badges).not.toContain('bug'); |
| 527 | } finally { await page.close(); } |
| 528 | } |
| 529 | |
| 530 | // Re-enable for subsequent tests |
| 531 | await adminCtx.request.post(`${BASE}/ulm-repo/settings`, { |
| 532 | form: { description: '', default_branch: 'main', allow_user_labels: '1' }, |
| 533 | maxRedirects: 0, |
| 534 | }).catch(() => {}); |
| 535 | }); |
| 536 | |
| 537 | // ── Add/remove labels on existing items ── |
| 538 | |
| 539 | test('non-admin can add label to their own issue', async () => { |
| 540 | const issueNum = aliceIssueUrl.split('/issues/')[1]; |
| 541 | const page = await aliceCtx.newPage(); |
| 542 | try { |
| 543 | await page.goto(aliceIssueUrl); |
| 544 | await page.selectOption('select[name=label_id]', { label: 'bug' }); |
| 545 | await page.click('form[action$="/labels/add"] button[type=submit]'); |
| 546 | await page.waitForURL(new RegExp(`/ulm-repo/issues/${issueNum}`)); |
| 547 | expect(await page.locator('.label-badge').allTextContents()).toContain('bug'); |
| 548 | } finally { await page.close(); } |
| 549 | }); |
| 550 | |
| 551 | test('non-admin can remove label from their own issue', async () => { |
| 552 | const issueNum = aliceIssueUrl.split('/issues/')[1]; |
| 553 | const page = await aliceCtx.newPage(); |
| 554 | try { |
| 555 | await page.goto(aliceIssueUrl); |
| 556 | await page.click('form[action$="/labels/remove"] button[type=submit]'); |
| 557 | await page.waitForURL(new RegExp(`/ulm-repo/issues/${issueNum}`)); |
| 558 | const badges = await page.locator('.issue-labels-row .label-badge').allTextContents(); |
| 559 | expect(badges).not.toContain('bug'); |
| 560 | } finally { await page.close(); } |
| 561 | }); |
| 562 | |
| 563 | test('non-admin cannot add label to another user\'s issue', async () => { |
| 564 | const issueNum = adminIssueUrl.split('/issues/')[1]; |
| 565 | const resp = await aliceCtx.request.post( |
| 566 | `${BASE}/ulm-repo/issues/${issueNum}/labels/add`, |
| 567 | { form: { label_id: bugLabelId }, maxRedirects: 0 }, |
| 568 | ); |
| 569 | expect(resp.status()).toBe(403); |
| 570 | }); |
| 571 | |
| 572 | test('unauthenticated user gets 401 adding a label', async () => { |
| 573 | const issueNum = aliceIssueUrl.split('/issues/')[1]; |
| 574 | const ctx = await browser.newContext(); |
| 575 | try { |
| 576 | const resp = await ctx.request.post( |
| 577 | `${BASE}/ulm-repo/issues/${issueNum}/labels/add`, |
| 578 | { form: { label_id: bugLabelId }, maxRedirects: 0 }, |
| 579 | ); |
| 580 | expect(resp.status()).toBe(401); |
| 581 | } finally { await ctx.close(); } |
| 582 | }); |
| 583 | |
| 584 | // ── Disable setting and verify enforcement ── |
| 585 | |
| 586 | test('admin can disable allow_user_labels', async () => { |
| 587 | const page = await adminCtx.newPage(); |
| 588 | try { |
| 589 | await page.goto(`${BASE}/ulm-repo/settings`); |
| 590 | await page.uncheck('input[name=allow_user_labels]'); |
| 591 | await page.click('form[action$="/settings"] button[type=submit]'); |
| 592 | await page.waitForURL(/\/ulm-repo\/settings/); |
| 593 | await page.goto(`${BASE}/ulm-repo/settings`); |
| 594 | expect(await page.locator('input[name=allow_user_labels]').isChecked()).toBe(false); |
| 595 | } finally { await page.close(); } |
| 596 | }); |
| 597 | |
| 598 | test('non-admin gets 403 adding label to own issue when allow_user_labels is off', async () => { |
| 599 | const issueNum = aliceIssueUrl.split('/issues/')[1]; |
| 600 | const resp = await aliceCtx.request.post( |
| 601 | `${BASE}/ulm-repo/issues/${issueNum}/labels/add`, |
| 602 | { form: { label_id: bugLabelId }, maxRedirects: 0 }, |
| 603 | ); |
| 604 | expect(resp.status()).toBe(403); |
| 605 | }); |
| 606 | |
| 607 | test('label checkboxes hidden on new issue form after allow_user_labels disabled', async () => { |
| 608 | const page = await aliceCtx.newPage(); |
| 609 | try { |
| 610 | await page.goto(`${BASE}/ulm-repo/issues/new`); |
| 611 | expect(await page.locator('.label-checkbox-list').count()).toBe(0); |
| 612 | } finally { await page.close(); } |
| 613 | }); |
| 614 | }); |
| 615 |