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(); } }); 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; } // ─── Releases ───────────────────────────────────────────────────────────────── describe('releases', () => { let adminCtx: BrowserContext; let aliceCtx: BrowserContext; let releaseUrl: string; let srcReleaseUrl: string; let releaseWithAssetsUrl: string; beforeAll(async () => { adminCtx = await loggedInContext(); aliceCtx = await loggedInContext('alice', 'password123'); // Create a dedicated repo with at least one commit const page = await adminCtx.newPage(); try { await page.goto(`${BASE}/new`); await page.fill('[name=name]', 'releases-repo'); await page.click('form[action="/new"] button[type=submit]'); await page.waitForURL(`${BASE}/releases-repo`); } finally { await page.close(); } await seedRepo('releases-repo'); }); afterAll(async () => { await adminCtx.close(); await aliceCtx.close(); }); // ── Navigation ────────────────────────────────────────────────────────────── test('releases tab visible in repo nav', async () => { const page = await adminCtx.newPage(); try { await page.goto(`${BASE}/releases-repo`); expect(await page.locator('.repo-tab', { hasText: 'Releases' }).isVisible()).toBe(true); } finally { await page.close(); } }); test('releases list shows empty state when no releases', async () => { const page = await adminCtx.newPage(); try { await page.goto(`${BASE}/releases-repo/releases`); expect(await page.locator('.empty-state').isVisible()).toBe(true); } finally { await page.close(); } }); // ── Access control ────────────────────────────────────────────────────────── test('non-admin cannot access /releases/new', async () => { const page = await aliceCtx.newPage(); try { const resp = await page.request.get(`${BASE}/releases-repo/releases/new`); expect(resp.status()).toBe(403); } finally { await page.close(); } }); test('non-admin POST to /releases returns 403', async () => { const page = await aliceCtx.newPage(); try { const resp = await page.request.post(`${BASE}/releases-repo/releases`, { multipart: { name: 'Test', tag_name: 'v0.1.0' }, maxRedirects: 0, }); expect(resp.status()).toBe(403); } finally { await page.close(); } }); test('unauthenticated user is redirected to login from /releases/new', async () => { const ctx = await browser.newContext(); const page = await ctx.newPage(); try { await page.goto(`${BASE}/releases-repo/releases/new`); expect(page.url()).toContain('/login'); } finally { await ctx.close(); } }); // ── Validation ────────────────────────────────────────────────────────────── test('missing release title shows error', async () => { const resp = await adminCtx.request.post(`${BASE}/releases-repo/releases`, { multipart: {}, }); expect(await resp.text()).toContain('Release title is required'); }); test('create_tag checked but no tag name shows error', async () => { const resp = await adminCtx.request.post(`${BASE}/releases-repo/releases`, { multipart: { name: 'Test', create_tag: 'on' }, }); expect(await resp.text()).toContain('Tag name is required'); }); // ── Create ────────────────────────────────────────────────────────────────── test('create a basic release', async () => { const resp = await adminCtx.request.post(`${BASE}/releases-repo/releases`, { multipart: { create_tag: 'on', tag_name: 'v1.0.0', revision: 'main', name: 'First release', notes: 'Initial stable release.\n\n- Feature A\n- Feature B', }, maxRedirects: 0, }); expect(resp.status()).toBe(302); const location = resp.headers()['location']!; expect(location).toMatch(/\/releases-repo\/releases\/\d+/); releaseUrl = `${BASE}${location}`; }); test('duplicate tag name shows error', async () => { const resp = await adminCtx.request.post(`${BASE}/releases-repo/releases`, { multipart: { name: 'Duplicate', create_tag: 'on', tag_name: 'v1.0.0', revision: 'main' }, }); expect(await resp.text()).toContain('already exists'); }); // ── List ──────────────────────────────────────────────────────────────────── test('release appears in list with tag badge', async () => { const page = await adminCtx.newPage(); try { await page.goto(`${BASE}/releases-repo/releases`); expect(await page.locator('.release-item-title').textContent()).toContain('First release'); expect(await page.locator('.badge').textContent()).toContain('v1.0.0'); } finally { await page.close(); } }); test('release list shows tag badge', async () => { const page = await adminCtx.newPage(); try { await page.goto(`${BASE}/releases-repo/releases`); expect(await page.locator('.badge').first().textContent()).toContain('v1.0.0'); } finally { await page.close(); } }); test('new release button hidden for non-admin', async () => { const page = await aliceCtx.newPage(); try { await page.goto(`${BASE}/releases-repo/releases`); expect(await page.locator('a[href$="/releases/new"]').count()).toBe(0); } finally { await page.close(); } }); // ── Detail ────────────────────────────────────────────────────────────────── test('release detail shows title and tag badge', async () => { const page = await adminCtx.newPage(); try { await page.goto(releaseUrl); expect(await page.locator('h2.page-title').textContent()).toBe('First release'); expect(await page.locator('.badge').textContent()).toContain('v1.0.0'); } finally { await page.close(); } }); test('release notes rendered in detail view', async () => { const page = await adminCtx.newPage(); try { await page.goto(releaseUrl); expect(await page.locator('.markdown-body').textContent()).toContain('Initial stable release'); } finally { await page.close(); } }); // ── Source archives ───────────────────────────────────────────────────────── test('create release with source code archives', async () => { const resp = await adminCtx.request.post(`${BASE}/releases-repo/releases`, { multipart: { name: 'Source release', create_tag: 'on', tag_name: 'v1.1.0', revision: 'main', include_source_code: 'on' }, maxRedirects: 0, }); expect(resp.status()).toBe(302); const location = resp.headers()['location']!; srcReleaseUrl = `${BASE}${location}`; }); test('zip and tar.gz archives appear in downloads', async () => { const page = await adminCtx.newPage(); try { await page.goto(srcReleaseUrl); const assetNames = await page.locator('.asset-name').allTextContents(); expect(assetNames.some(n => n.endsWith('.zip'))).toBe(true); expect(assetNames.some(n => n.endsWith('.tar.gz'))).toBe(true); } finally { await page.close(); } }); test('source archive download responds with 200', async () => { const page = await adminCtx.newPage(); try { await page.goto(srcReleaseUrl); const zipLink = await page.locator('.asset-name', { hasText: '.zip' }).getAttribute('href'); const resp = await page.request.get(`${BASE}${zipLink}`); expect(resp.status()).toBe(200); } finally { await page.close(); } }); // ── File upload ───────────────────────────────────────────────────────────── test('create release with attached file', async () => { const resp = await adminCtx.request.post(`${BASE}/releases-repo/releases`, { multipart: { name: 'Asset release', create_tag: 'on', tag_name: 'v1.2.0', revision: 'main', files: { name: 'release-asset.txt', mimeType: 'text/plain', buffer: Buffer.from('binary-like content for testing\n'), }, }, maxRedirects: 0, }); expect(resp.status()).toBe(302); const location = resp.headers()['location']!; releaseWithAssetsUrl = `${BASE}${location}`; }); test('uploaded asset appears in downloads with filename and size', async () => { const page = await adminCtx.newPage(); try { await page.goto(releaseWithAssetsUrl); const names = await page.locator('.asset-name').allTextContents(); expect(names.some(n => n.includes('release-asset.txt'))).toBe(true); expect(await page.locator('.asset-size').isVisible()).toBe(true); } finally { await page.close(); } }); test('asset download responds with 200', async () => { const page = await adminCtx.newPage(); try { await page.goto(releaseWithAssetsUrl); const link = await page.locator('.asset-name', { hasText: 'release-asset.txt' }).getAttribute('href'); const resp = await page.request.get(`${BASE}${link}`); expect(resp.status()).toBe(200); } finally { await page.close(); } }); // ── Delete ────────────────────────────────────────────────────────────────── test('non-admin cannot delete a release', async () => { const page = await aliceCtx.newPage(); try { const idMatch = releaseUrl.match(/\/releases\/(\d+)/); const resp = await page.request.post(`${BASE}/releases-repo/releases/${idMatch![1]}/delete`, { maxRedirects: 0, }); expect(resp.status()).toBe(403); } finally { await page.close(); } }); test('admin can delete a release', async () => { const idMatch = releaseUrl.match(/\/releases\/(\d+)/); const resp = await adminCtx.request.post( `${BASE}/releases-repo/releases/${idMatch![1]}/delete`, { maxRedirects: 0 }, ); expect(resp.status()).toBe(302); // Verify it's gone from the list const page = await adminCtx.newPage(); try { await page.goto(`${BASE}/releases-repo/releases`); const titles = await page.locator('.release-item-title').allTextContents(); expect(titles.some(t => t.includes('First release'))).toBe(false); } finally { await page.close(); } }); // ── Pagination ────────────────────────────────────────────────────────────── test('release list shows at most 20 per page', async () => { // Bulk-create 25 releases with a distinct prefix to guarantee > 20 total // regardless of which browser-based tests above succeeded for (let i = 1; i <= 25; i++) { await adminCtx.request.post(`${BASE}/releases-repo/releases`, { multipart: { name: `Page test release ${i}`, create_tag: 'on', tag_name: `v9.${i}.0`, revision: 'main' }, maxRedirects: 0, }).catch(() => {}); } const page = await adminCtx.newPage(); try { await page.goto(`${BASE}/releases-repo/releases`); expect(await page.locator('.issue-item').count()).toBeLessThanOrEqual(20); } finally { await page.close(); } }); test('pagination nav appears with more than 20 releases', async () => { const page = await adminCtx.newPage(); try { await page.goto(`${BASE}/releases-repo/releases`); expect(await page.locator('.pagination').isVisible()).toBe(true); } finally { await page.close(); } }); test('release list page 2 shows remaining releases', async () => { const page = await adminCtx.newPage(); try { await page.goto(`${BASE}/releases-repo/releases?page=2`); const count = await page.locator('.issue-item').count(); expect(count).toBeGreaterThan(0); expect(count).toBeLessThanOrEqual(20); } finally { await page.close(); } }); test('source archive tar.zst appears in downloads', async () => { const zstd = Bun.spawnSync({ cmd: ['which', 'zstd'] }); if (zstd.exitCode !== 0) { console.log('zstd not available, skipping tar.zst test'); return; } const page = await adminCtx.newPage(); try { await page.goto(srcReleaseUrl); const assetNames = await page.locator('.asset-name').allTextContents(); expect(assetNames.some(n => n.endsWith('.tar.zst'))).toBe(true); } finally { await page.close(); } }); });