e2e.releases.test.ts
Raw
1import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
2import { chromium } from 'playwright';
3import type { Browser, BrowserContext } from 'playwright';
4import {
5 BASE,
6 ADMIN_PASS,
7 setupTestEnv,
8 spawnServer,
9 killServer,
10 login,
11 seedRepo,
12} from './helpers.ts';
13
14let browser: Browser;
15let server: Awaited<ReturnType<typeof spawnServer>>;
16
17beforeAll(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
35afterAll(async () => {
36 await browser.close();
37 await killServer(server);
38});
39
40async 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
50describe('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