e2e.releases.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 | seedRepo, |
| 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 |
| 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 | // ─── Releases ───────────────────────────────────────────────────────────────── |
| 49 | |
| 50 | describe('releases', () => { |
| 51 | let adminCtx: BrowserContext; |
| 52 | let aliceCtx: BrowserContext; |
| 53 | let releaseUrl: string; |
| 54 | let srcReleaseUrl: string; |
| 55 | let releaseWithAssetsUrl: string; |
| 56 | |
| 57 | beforeAll(async () => { |
| 58 | adminCtx = await loggedInContext(); |
| 59 | aliceCtx = await loggedInContext('alice', 'password123'); |
| 60 | |
| 61 | // Create a dedicated repo with at least one commit |
| 62 | const page = await adminCtx.newPage(); |
| 63 | try { |
| 64 | await page.goto(`${BASE}/new`); |
| 65 | await page.fill('[name=name]', 'releases-repo'); |
| 66 | await page.click('form[action="/new"] button[type=submit]'); |
| 67 | await page.waitForURL(`${BASE}/releases-repo`); |
| 68 | } finally { await page.close(); } |
| 69 | |
| 70 | await seedRepo('releases-repo'); |
| 71 | }); |
| 72 | |
| 73 | afterAll(async () => { |
| 74 | await adminCtx.close(); |
| 75 | await aliceCtx.close(); |
| 76 | }); |
| 77 | |
| 78 | // ── Navigation ────────────────────────────────────────────────────────────── |
| 79 | |
| 80 | test('releases tab visible in repo nav', async () => { |
| 81 | const page = await adminCtx.newPage(); |
| 82 | try { |
| 83 | await page.goto(`${BASE}/releases-repo`); |
| 84 | expect(await page.locator('.repo-tab', { hasText: 'Releases' }).isVisible()).toBe(true); |
| 85 | } finally { await page.close(); } |
| 86 | }); |
| 87 | |
| 88 | test('releases list shows empty state when no releases', async () => { |
| 89 | const page = await adminCtx.newPage(); |
| 90 | try { |
| 91 | await page.goto(`${BASE}/releases-repo/releases`); |
| 92 | expect(await page.locator('.empty-state').isVisible()).toBe(true); |
| 93 | } finally { await page.close(); } |
| 94 | }); |
| 95 | |
| 96 | // ── Access control ────────────────────────────────────────────────────────── |
| 97 | |
| 98 | test('non-admin cannot access /releases/new', async () => { |
| 99 | const page = await aliceCtx.newPage(); |
| 100 | try { |
| 101 | const resp = await page.request.get(`${BASE}/releases-repo/releases/new`); |
| 102 | expect(resp.status()).toBe(403); |
| 103 | } finally { await page.close(); } |
| 104 | }); |
| 105 | |
| 106 | test('non-admin POST to /releases returns 403', async () => { |
| 107 | const page = await aliceCtx.newPage(); |
| 108 | try { |
| 109 | const resp = await page.request.post(`${BASE}/releases-repo/releases`, { |
| 110 | multipart: { name: 'Test', tag_name: 'v0.1.0' }, |
| 111 | maxRedirects: 0, |
| 112 | }); |
| 113 | expect(resp.status()).toBe(403); |
| 114 | } finally { await page.close(); } |
| 115 | }); |
| 116 | |
| 117 | test('unauthenticated user is redirected to login from /releases/new', async () => { |
| 118 | const ctx = await browser.newContext(); |
| 119 | const page = await ctx.newPage(); |
| 120 | try { |
| 121 | await page.goto(`${BASE}/releases-repo/releases/new`); |
| 122 | expect(page.url()).toContain('/login'); |
| 123 | } finally { await ctx.close(); } |
| 124 | }); |
| 125 | |
| 126 | // ── Validation ────────────────────────────────────────────────────────────── |
| 127 | |
| 128 | test('missing release title shows error', async () => { |
| 129 | const resp = await adminCtx.request.post(`${BASE}/releases-repo/releases`, { |
| 130 | multipart: {}, |
| 131 | }); |
| 132 | expect(await resp.text()).toContain('Release title is required'); |
| 133 | }); |
| 134 | |
| 135 | test('create_tag checked but no tag name shows error', async () => { |
| 136 | const resp = await adminCtx.request.post(`${BASE}/releases-repo/releases`, { |
| 137 | multipart: { name: 'Test', create_tag: 'on' }, |
| 138 | }); |
| 139 | expect(await resp.text()).toContain('Tag name is required'); |
| 140 | }); |
| 141 | |
| 142 | // ── Create ────────────────────────────────────────────────────────────────── |
| 143 | |
| 144 | test('create a basic release', async () => { |
| 145 | const resp = await adminCtx.request.post(`${BASE}/releases-repo/releases`, { |
| 146 | multipart: { |
| 147 | create_tag: 'on', |
| 148 | tag_name: 'v1.0.0', |
| 149 | revision: 'main', |
| 150 | name: 'First release', |
| 151 | notes: 'Initial stable release.\n\n- Feature A\n- Feature B', |
| 152 | }, |
| 153 | maxRedirects: 0, |
| 154 | }); |
| 155 | expect(resp.status()).toBe(302); |
| 156 | const location = resp.headers()['location']!; |
| 157 | expect(location).toMatch(/\/releases-repo\/releases\/\d+/); |
| 158 | releaseUrl = `${BASE}${location}`; |
| 159 | }); |
| 160 | |
| 161 | test('duplicate tag name shows error', async () => { |
| 162 | const resp = await adminCtx.request.post(`${BASE}/releases-repo/releases`, { |
| 163 | multipart: { name: 'Duplicate', create_tag: 'on', tag_name: 'v1.0.0', revision: 'main' }, |
| 164 | }); |
| 165 | expect(await resp.text()).toContain('already exists'); |
| 166 | }); |
| 167 | |
| 168 | // ── List ──────────────────────────────────────────────────────────────────── |
| 169 | |
| 170 | test('release appears in list with tag badge', async () => { |
| 171 | const page = await adminCtx.newPage(); |
| 172 | try { |
| 173 | await page.goto(`${BASE}/releases-repo/releases`); |
| 174 | expect(await page.locator('.release-item-title').textContent()).toContain('First release'); |
| 175 | expect(await page.locator('.badge').textContent()).toContain('v1.0.0'); |
| 176 | } finally { await page.close(); } |
| 177 | }); |
| 178 | |
| 179 | test('release list shows tag badge', async () => { |
| 180 | const page = await adminCtx.newPage(); |
| 181 | try { |
| 182 | await page.goto(`${BASE}/releases-repo/releases`); |
| 183 | expect(await page.locator('.badge').first().textContent()).toContain('v1.0.0'); |
| 184 | } finally { await page.close(); } |
| 185 | }); |
| 186 | |
| 187 | test('new release button hidden for non-admin', async () => { |
| 188 | const page = await aliceCtx.newPage(); |
| 189 | try { |
| 190 | await page.goto(`${BASE}/releases-repo/releases`); |
| 191 | expect(await page.locator('a[href$="/releases/new"]').count()).toBe(0); |
| 192 | } finally { await page.close(); } |
| 193 | }); |
| 194 | |
| 195 | // ── Detail ────────────────────────────────────────────────────────────────── |
| 196 | |
| 197 | test('release detail shows title and tag badge', async () => { |
| 198 | const page = await adminCtx.newPage(); |
| 199 | try { |
| 200 | await page.goto(releaseUrl); |
| 201 | expect(await page.locator('h2.page-title').textContent()).toBe('First release'); |
| 202 | expect(await page.locator('.badge').textContent()).toContain('v1.0.0'); |
| 203 | } finally { await page.close(); } |
| 204 | }); |
| 205 | |
| 206 | test('release notes rendered in detail view', async () => { |
| 207 | const page = await adminCtx.newPage(); |
| 208 | try { |
| 209 | await page.goto(releaseUrl); |
| 210 | expect(await page.locator('.markdown-body').textContent()).toContain('Initial stable release'); |
| 211 | } finally { await page.close(); } |
| 212 | }); |
| 213 | |
| 214 | // ── Source archives ───────────────────────────────────────────────────────── |
| 215 | |
| 216 | test('create release with source code archives', async () => { |
| 217 | const resp = await adminCtx.request.post(`${BASE}/releases-repo/releases`, { |
| 218 | multipart: { name: 'Source release', create_tag: 'on', tag_name: 'v1.1.0', revision: 'main', include_source_code: 'on' }, |
| 219 | maxRedirects: 0, |
| 220 | }); |
| 221 | expect(resp.status()).toBe(302); |
| 222 | const location = resp.headers()['location']!; |
| 223 | srcReleaseUrl = `${BASE}${location}`; |
| 224 | }); |
| 225 | |
| 226 | test('zip and tar.gz archives appear in downloads', async () => { |
| 227 | const page = await adminCtx.newPage(); |
| 228 | try { |
| 229 | await page.goto(srcReleaseUrl); |
| 230 | const assetNames = await page.locator('.asset-name').allTextContents(); |
| 231 | expect(assetNames.some(n => n.endsWith('.zip'))).toBe(true); |
| 232 | expect(assetNames.some(n => n.endsWith('.tar.gz'))).toBe(true); |
| 233 | } finally { await page.close(); } |
| 234 | }); |
| 235 | |
| 236 | test('source archive download responds with 200', async () => { |
| 237 | const page = await adminCtx.newPage(); |
| 238 | try { |
| 239 | await page.goto(srcReleaseUrl); |
| 240 | const zipLink = await page.locator('.asset-name', { hasText: '.zip' }).getAttribute('href'); |
| 241 | const resp = await page.request.get(`${BASE}${zipLink}`); |
| 242 | expect(resp.status()).toBe(200); |
| 243 | } finally { await page.close(); } |
| 244 | }); |
| 245 | |
| 246 | // ── File upload ───────────────────────────────────────────────────────────── |
| 247 | |
| 248 | test('create release with attached file', async () => { |
| 249 | const resp = await adminCtx.request.post(`${BASE}/releases-repo/releases`, { |
| 250 | multipart: { |
| 251 | name: 'Asset release', |
| 252 | create_tag: 'on', |
| 253 | tag_name: 'v1.2.0', |
| 254 | revision: 'main', |
| 255 | files: { |
| 256 | name: 'release-asset.txt', |
| 257 | mimeType: 'text/plain', |
| 258 | buffer: Buffer.from('binary-like content for testing\n'), |
| 259 | }, |
| 260 | }, |
| 261 | maxRedirects: 0, |
| 262 | }); |
| 263 | expect(resp.status()).toBe(302); |
| 264 | const location = resp.headers()['location']!; |
| 265 | releaseWithAssetsUrl = `${BASE}${location}`; |
| 266 | }); |
| 267 | |
| 268 | test('uploaded asset appears in downloads with filename and size', async () => { |
| 269 | const page = await adminCtx.newPage(); |
| 270 | try { |
| 271 | await page.goto(releaseWithAssetsUrl); |
| 272 | const names = await page.locator('.asset-name').allTextContents(); |
| 273 | expect(names.some(n => n.includes('release-asset.txt'))).toBe(true); |
| 274 | expect(await page.locator('.asset-size').isVisible()).toBe(true); |
| 275 | } finally { await page.close(); } |
| 276 | }); |
| 277 | |
| 278 | test('asset download responds with 200', async () => { |
| 279 | const page = await adminCtx.newPage(); |
| 280 | try { |
| 281 | await page.goto(releaseWithAssetsUrl); |
| 282 | const link = await page.locator('.asset-name', { hasText: 'release-asset.txt' }).getAttribute('href'); |
| 283 | const resp = await page.request.get(`${BASE}${link}`); |
| 284 | expect(resp.status()).toBe(200); |
| 285 | } finally { await page.close(); } |
| 286 | }); |
| 287 | |
| 288 | // ── Delete ────────────────────────────────────────────────────────────────── |
| 289 | |
| 290 | test('non-admin cannot delete a release', async () => { |
| 291 | const page = await aliceCtx.newPage(); |
| 292 | try { |
| 293 | const idMatch = releaseUrl.match(/\/releases\/(\d+)/); |
| 294 | const resp = await page.request.post(`${BASE}/releases-repo/releases/${idMatch![1]}/delete`, { |
| 295 | maxRedirects: 0, |
| 296 | }); |
| 297 | expect(resp.status()).toBe(403); |
| 298 | } finally { await page.close(); } |
| 299 | }); |
| 300 | |
| 301 | test('admin can delete a release', async () => { |
| 302 | const idMatch = releaseUrl.match(/\/releases\/(\d+)/); |
| 303 | const resp = await adminCtx.request.post( |
| 304 | `${BASE}/releases-repo/releases/${idMatch![1]}/delete`, |
| 305 | { maxRedirects: 0 }, |
| 306 | ); |
| 307 | expect(resp.status()).toBe(302); |
| 308 | // Verify it's gone from the list |
| 309 | const page = await adminCtx.newPage(); |
| 310 | try { |
| 311 | await page.goto(`${BASE}/releases-repo/releases`); |
| 312 | const titles = await page.locator('.release-item-title').allTextContents(); |
| 313 | expect(titles.some(t => t.includes('First release'))).toBe(false); |
| 314 | } finally { await page.close(); } |
| 315 | }); |
| 316 | |
| 317 | // ── Pagination ────────────────────────────────────────────────────────────── |
| 318 | |
| 319 | test('release list shows at most 20 per page', async () => { |
| 320 | // Bulk-create 25 releases with a distinct prefix to guarantee > 20 total |
| 321 | // regardless of which browser-based tests above succeeded |
| 322 | for (let i = 1; i <= 25; i++) { |
| 323 | await adminCtx.request.post(`${BASE}/releases-repo/releases`, { |
| 324 | multipart: { name: `Page test release ${i}`, create_tag: 'on', tag_name: `v9.${i}.0`, revision: 'main' }, |
| 325 | maxRedirects: 0, |
| 326 | }).catch(() => {}); |
| 327 | } |
| 328 | const page = await adminCtx.newPage(); |
| 329 | try { |
| 330 | await page.goto(`${BASE}/releases-repo/releases`); |
| 331 | expect(await page.locator('.issue-item').count()).toBeLessThanOrEqual(20); |
| 332 | } finally { await page.close(); } |
| 333 | }); |
| 334 | |
| 335 | test('pagination nav appears with more than 20 releases', async () => { |
| 336 | const page = await adminCtx.newPage(); |
| 337 | try { |
| 338 | await page.goto(`${BASE}/releases-repo/releases`); |
| 339 | expect(await page.locator('.pagination').isVisible()).toBe(true); |
| 340 | } finally { await page.close(); } |
| 341 | }); |
| 342 | |
| 343 | test('release list page 2 shows remaining releases', async () => { |
| 344 | const page = await adminCtx.newPage(); |
| 345 | try { |
| 346 | await page.goto(`${BASE}/releases-repo/releases?page=2`); |
| 347 | const count = await page.locator('.issue-item').count(); |
| 348 | expect(count).toBeGreaterThan(0); |
| 349 | expect(count).toBeLessThanOrEqual(20); |
| 350 | } finally { await page.close(); } |
| 351 | }); |
| 352 | |
| 353 | test('source archive tar.zst appears in downloads', async () => { |
| 354 | const zstd = Bun.spawnSync({ cmd: ['which', 'zstd'] }); |
| 355 | if (zstd.exitCode !== 0) { |
| 356 | console.log('zstd not available, skipping tar.zst test'); |
| 357 | return; |
| 358 | } |
| 359 | |
| 360 | const page = await adminCtx.newPage(); |
| 361 | try { |
| 362 | await page.goto(srcReleaseUrl); |
| 363 | const assetNames = await page.locator('.asset-name').allTextContents(); |
| 364 | expect(assetNames.some(n => n.endsWith('.tar.zst'))).toBe(true); |
| 365 | } finally { await page.close(); } |
| 366 | }); |
| 367 | }); |
| 368 |