e2e.git-ops.test.ts
Raw
1import { afterAll, beforeAll, describe, expect, test } from 'bun:test';
2import { chromium } from 'playwright';
3import type { Browser, BrowserContext } from 'playwright';
4import {
5 ADMIN_PASS,
6 BASE,
7 DATA_DIR,
8 getHeadCommit,
9 gitOutput,
10 killServer,
11 login,
12 seedBranch,
13 seedRepo,
14 setupTestEnv,
15 spawnServer,
16} from './helpers.ts';
17
18let browser: Browser;
19let server: Awaited<ReturnType<typeof spawnServer>>;
20let adminCtx: BrowserContext;
21
22beforeAll(async () => {
23 await setupTestEnv();
24 server = await spawnServer();
25 browser = await chromium.launch();
26
27 // Single admin context shared across all describes
28 adminCtx = await browser.newContext();
29 const page = await adminCtx.newPage();
30 await login(page, 'admin', ADMIN_PASS);
31 await page.close();
32
33 // Create all repos upfront
34 for (const name of ['branch-repo', 'tag-repo', 'selector-repo', 'newfile-repo', 'delfile-repo', 'movefile-repo']) {
35 const p = await adminCtx.newPage();
36 try {
37 await p.goto(`${BASE}/new`);
38 await p.fill('[name=name]', name);
39 await p.click('form[action="/new"] button[type=submit]');
40 await p.waitForURL(`${BASE}/${name}`);
41 } finally { await p.close(); }
42 }
43
44 // Seed repos
45 await seedRepo('branch-repo');
46 seedBranch('branch-repo', 'feature-a');
47
48 await seedRepo('tag-repo');
49 gitOutput(['-C', repoDir('tag-repo'), 'tag', 'v0.1.0', 'HEAD']);
50
51 await seedRepo('selector-repo');
52 gitOutput(['-C', repoDir('selector-repo'), 'tag', 'stable', 'HEAD']);
53
54 await seedRepo('newfile-repo');
55 await seedRepo('delfile-repo');
56 await seedRepo('movefile-repo');
57});
58
59afterAll(async () => {
60 await adminCtx.close();
61 await browser.close();
62 await killServer(server);
63});
64
65function repoDir(name: string) {
66 return `${process.cwd()}/${DATA_DIR}/repos/${name}.git`;
67}
68
69// ─── Branch management ────────────────────────────────────────────────────────
70
71describe('branches', () => {
72 test('Branches tab appears in repo nav', async () => {
73 const page = await adminCtx.newPage();
74 try {
75 await page.goto(`${BASE}/branch-repo`);
76 const tab = page.locator('a.repo-tab', { hasText: 'Branches' });
77 expect(await tab.isVisible()).toBe(true);
78 } finally { await page.close(); }
79 });
80
81 test('branches page lists all branches', async () => {
82 const page = await adminCtx.newPage();
83 try {
84 await page.goto(`${BASE}/branch-repo/branches`);
85 const content = await page.content();
86 expect(content).toContain('main');
87 expect(content).toContain('feature-a');
88 } finally { await page.close(); }
89 });
90
91 test('default branch has a "default" badge', async () => {
92 const page = await adminCtx.newPage();
93 try {
94 await page.goto(`${BASE}/branch-repo/branches`);
95 expect(await page.locator('.badge', { hasText: 'default' }).count()).toBeGreaterThan(0);
96 } finally { await page.close(); }
97 });
98
99 test('branch list shows last commit hash and subject', async () => {
100 const sha = getHeadCommit('branch-repo').slice(0, 7);
101 const page = await adminCtx.newPage();
102 try {
103 await page.goto(`${BASE}/branch-repo/branches`);
104 const content = await page.content();
105 expect(content).toContain(sha);
106 expect(content).toContain('Initial commit');
107 } finally { await page.close(); }
108 });
109
110 test('branches page returns 404 for non-existent repo', async () => {
111 const ctx = await browser.newContext();
112 const page = await ctx.newPage();
113 try {
114 const resp = await page.request.get(`${BASE}/does-not-exist/branches`);
115 expect(resp.status()).toBe(404);
116 } finally {
117 await page.close();
118 await ctx.close();
119 }
120 });
121
122 test('create a new branch', async () => {
123 const page = await adminCtx.newPage();
124 try {
125 await page.goto(`${BASE}/branch-repo/branches`);
126 await page.click('details:has(summary:text("New branch")) summary');
127 await page.fill('[name=name]', 'new-feature');
128 await page.fill('[name=source_ref]', 'main');
129 await page.click('form[action*="branches/create"] button[type=submit]');
130 await page.waitForURL(/branches/);
131 expect(await page.locator('.ref-item').filter({ hasText: 'new-feature' }).count()).toBeGreaterThan(0);
132 } finally { await page.close(); }
133 });
134
135 test('creating branch with invalid name shows error', async () => {
136 const resp = await adminCtx.request.post(`${BASE}/branch-repo/branches/create`, {
137 form: { name: '--invalid', source_ref: 'main' },
138 });
139 expect(resp.url()).toContain('error');
140 });
141
142 test('creating branch from non-existent ref shows error', async () => {
143 const resp = await adminCtx.request.post(`${BASE}/branch-repo/branches/create`, {
144 form: { name: 'bad-branch', source_ref: 'does-not-exist' },
145 });
146 expect(resp.url()).toContain('error');
147 });
148
149 test('rename a branch', async () => {
150 const page = await adminCtx.newPage();
151 try {
152 await page.goto(`${BASE}/branch-repo/branches`);
153 const row = page.locator('.ref-item').filter({ hasText: 'feature-a' });
154 await row.locator('details:has(summary:text("Rename")) summary').click();
155 await row.locator('[name=new_name]').fill('feature-renamed');
156 await row.locator('form[action*="branches/rename"] button[type=submit]').click();
157 await page.waitForURL(/branches/);
158 expect(await page.locator('.ref-item').filter({ hasText: 'feature-renamed' }).count()).toBeGreaterThan(0);
159 expect(await page.locator('.ref-item').filter({ hasText: 'feature-a' }).count()).toBe(0);
160 } finally { await page.close(); }
161 });
162
163 test('delete a non-default branch', async () => {
164 const page = await adminCtx.newPage();
165 try {
166 await page.goto(`${BASE}/branch-repo/branches`);
167 const row = page.locator('.ref-item').filter({ hasText: 'new-feature' });
168 await row.locator('details:has(summary:text("Delete")) summary').click();
169 await row.locator('form[action*="branches/delete"] button[type=submit]').click();
170 await page.waitForURL(/branches/);
171 expect(await page.locator('.ref-item').filter({ hasText: 'new-feature' }).count()).toBe(0);
172 } finally { await page.close(); }
173 });
174
175 test('cannot delete the default branch (no Delete button on main row)', async () => {
176 const page = await adminCtx.newPage();
177 try {
178 await page.goto(`${BASE}/branch-repo/branches`);
179 const mainRow = page.locator('.ref-item').filter({ hasText: 'main' });
180 expect(await mainRow.locator('summary:text("Delete")').count()).toBe(0);
181 } finally { await page.close(); }
182 });
183
184 test('renaming default branch updates it in repo', async () => {
185 const page = await adminCtx.newPage();
186 try {
187 await page.goto(`${BASE}/branch-repo/branches`);
188 const row = page.locator('.ref-item').filter({ hasText: 'main' });
189 await row.locator('details:has(summary:text("Rename")) summary').click();
190 await row.locator('[name=new_name]').fill('trunk');
191 await row.locator('form[action*="branches/rename"] button[type=submit]').click();
192 await page.waitForURL(/branches/);
193 expect(await page.locator('.ref-item').filter({ hasText: 'trunk' }).locator('.badge', { hasText: 'default' }).count()).toBeGreaterThan(0);
194 // Rename back
195 await page.goto(`${BASE}/branch-repo/branches`);
196 const trunkRow = page.locator('.ref-item').filter({ hasText: 'trunk' });
197 await trunkRow.locator('details:has(summary:text("Rename")) summary').click();
198 await trunkRow.locator('[name=new_name]').fill('main');
199 await trunkRow.locator('form[action*="branches/rename"] button[type=submit]').click();
200 await page.waitForURL(/branches/);
201 } finally { await page.close(); }
202 });
203});
204
205// ─── Tag management ───────────────────────────────────────────────────────────
206
207describe('tags', () => {
208 test('Tags tab appears in repo nav', async () => {
209 const page = await adminCtx.newPage();
210 try {
211 await page.goto(`${BASE}/tag-repo`);
212 const tab = page.locator('a.repo-tab', { hasText: 'Tags' });
213 expect(await tab.isVisible()).toBe(true);
214 } finally { await page.close(); }
215 });
216
217 test('tags page lists existing tags', async () => {
218 const page = await adminCtx.newPage();
219 try {
220 await page.goto(`${BASE}/tag-repo/tags`);
221 expect(await page.content()).toContain('v0.1.0');
222 } finally { await page.close(); }
223 });
224
225 test('tag links to correct tree view', async () => {
226 const page = await adminCtx.newPage();
227 try {
228 await page.goto(`${BASE}/tag-repo/tags`);
229 const link = page.locator(`a[href="/${['tag-repo', 'tree', 'v0.1.0'].join('/')}"]`);
230 expect(await link.count()).toBeGreaterThan(0);
231 } finally { await page.close(); }
232 });
233
234 test('create a new tag', async () => {
235 const page = await adminCtx.newPage();
236 try {
237 await page.goto(`${BASE}/tag-repo/tags`);
238 await page.click('details:has(summary:text("New tag")) summary');
239 await page.fill('[name=name]', 'v1.0.0');
240 await page.click('form[action*="tags/create"] button[type=submit]');
241 await page.waitForURL(/tags/);
242 expect(await page.locator('.ref-item').filter({ hasText: 'v1.0.0' }).count()).toBeGreaterThan(0);
243 } finally { await page.close(); }
244 });
245
246 test('create annotated tag with message', async () => {
247 const page = await adminCtx.newPage();
248 try {
249 await page.goto(`${BASE}/tag-repo/tags`);
250 await page.click('details:has(summary:text("New tag")) summary');
251 await page.fill('[name=name]', 'v1.1.0-annotated');
252 await page.fill('[name=message]', 'Annotated release tag');
253 await page.click('form[action*="tags/create"] button[type=submit]');
254 await page.waitForURL(/tags/);
255 expect(await page.locator('.ref-item').filter({ hasText: 'v1.1.0-annotated' }).count()).toBeGreaterThan(0);
256 } finally { await page.close(); }
257 });
258
259 test('delete a tag', async () => {
260 const page = await adminCtx.newPage();
261 try {
262 await page.goto(`${BASE}/tag-repo/tags`);
263 const row = page.locator('.ref-item').filter({ hasText: 'v1.0.0' });
264 await row.locator('details:has(summary:text("Delete")) summary').click();
265 await row.locator('form[action*="tags/delete"] button[type=submit]').click();
266 await page.waitForURL(/tags/);
267 expect(await page.locator('.ref-item').filter({ hasText: 'v1.0.0' }).count()).toBe(0);
268 expect(await page.locator('.ref-item').filter({ hasText: 'v0.1.0' }).count()).toBeGreaterThan(0);
269 } finally { await page.close(); }
270 });
271
272 test('tag linked to release shows release badge and warning on delete', async () => {
273 const resp = await adminCtx.request.post(`${BASE}/tag-repo/releases`, {
274 multipart: { name: 'Linked Release', create_tag: 'on', tag_name: 'v-linked', revision: 'main' },
275 maxRedirects: 0,
276 });
277 expect(resp.status()).toBe(302);
278
279 const page = await adminCtx.newPage();
280 try {
281 await page.goto(`${BASE}/tag-repo/tags`);
282 const row = page.locator('.ref-item').filter({ hasText: 'v-linked' });
283 expect(await row.locator('.badge-release').count()).toBeGreaterThan(0);
284 await row.locator('details:has(summary:text("Delete")) summary').click();
285 expect(await row.locator('.confirm-warning').count()).toBeGreaterThan(0);
286 } finally { await page.close(); }
287 });
288});
289
290// ─── BranchSelector with tags ─────────────────────────────────────────────────
291
292describe('BranchSelector tags integration', () => {
293 test('branch selector shows Tags optgroup when tags exist', async () => {
294 const page = await adminCtx.newPage();
295 try {
296 await page.goto(`${BASE}/selector-repo`);
297 expect(await page.locator('optgroup[label="Tags"]').count()).toBe(1);
298 expect(await page.locator('option', { hasText: 'stable' }).count()).toBeGreaterThan(0);
299 } finally { await page.close(); }
300 });
301
302 test('branch selector shows Branches optgroup when tags exist', async () => {
303 const page = await adminCtx.newPage();
304 try {
305 await page.goto(`${BASE}/selector-repo`);
306 expect(await page.locator('optgroup[label="Branches"]').count()).toBe(1);
307 } finally { await page.close(); }
308 });
309
310 test('branch selector on blob view includes tag optgroup', async () => {
311 const page = await adminCtx.newPage();
312 try {
313 await page.goto(`${BASE}/selector-repo/blob/main/README.md`);
314 expect(await page.locator('optgroup[label="Tags"]').count()).toBe(1);
315 } finally { await page.close(); }
316 });
317});
318
319// ─── File creation ────────────────────────────────────────────────────────────
320
321describe('file creation', () => {
322 test('New file button appears in tree toolbar for admin on a branch', async () => {
323 const page = await adminCtx.newPage();
324 try {
325 await page.goto(`${BASE}/newfile-repo/tree/main`);
326 expect(await page.locator('a[href*="/new-file/main"]').count()).toBeGreaterThan(0);
327 } finally { await page.close(); }
328 });
329
330 test('New file button not visible on commit SHA view', async () => {
331 const sha = getHeadCommit('newfile-repo');
332 const page = await adminCtx.newPage();
333 try {
334 await page.goto(`${BASE}/newfile-repo/tree/${sha}`);
335 expect(await page.locator('a[href*="/new-file/"]').count()).toBe(0);
336 } finally { await page.close(); }
337 });
338
339 test('New file button not visible to unauthenticated user', async () => {
340 const ctx = await browser.newContext();
341 const page = await ctx.newPage();
342 try {
343 await page.goto(`${BASE}/newfile-repo/tree/main`);
344 expect(await page.locator('a[href*="/new-file/main"]').count()).toBe(0);
345 } finally {
346 await page.close();
347 await ctx.close();
348 }
349 });
350
351 test('new file form pre-fills dir when ?dir= query param is provided', async () => {
352 const page = await adminCtx.newPage();
353 try {
354 await page.goto(`${BASE}/newfile-repo/new-file/main?dir=src`);
355 const pathInput = await page.locator('[name=path]').inputValue();
356 expect(pathInput).toBe('src/');
357 } finally { await page.close(); }
358 });
359
360 test('creating a new file creates a commit and redirects to commit view', async () => {
361 const resp = await adminCtx.request.post(`${BASE}/newfile-repo/new-file/main`, {
362 form: { path: 'hello.txt', content: 'Hello, world!\n', message: 'Add hello.txt' },
363 maxRedirects: 0,
364 });
365 expect(resp.status()).toBe(302);
366 expect(resp.headers()['location']).toMatch(/\/newfile-repo\/commit\/[0-9a-f]{40}/);
367 });
368
369 test('new file appears in tree after creation', async () => {
370 const page = await adminCtx.newPage();
371 try {
372 await page.goto(`${BASE}/newfile-repo/tree/main`);
373 expect(await page.content()).toContain('hello.txt');
374 } finally { await page.close(); }
375 });
376
377 test('new file commit is signed', async () => {
378 const hash = gitOutput(['-C', repoDir('newfile-repo'), 'log', '--format=%H', '--grep=Add hello.txt', '-1']);
379 expect(hash).toBeTruthy();
380 const obj = gitOutput(['-C', repoDir('newfile-repo'), 'cat-file', '-p', hash]);
381 expect(obj).toContain('gpgsig');
382 });
383
384 test('creating file with invalid path shows error', async () => {
385 const resp = await adminCtx.request.post(`${BASE}/newfile-repo/new-file/main`, {
386 form: { path: '../escape', content: '', message: 'bad' },
387 });
388 expect(resp.url()).toContain('error');
389 });
390});
391
392// ─── File deletion ────────────────────────────────────────────────────────────
393
394describe('file deletion', () => {
395 test('Delete button appears in file blob for admin on a branch', async () => {
396 const page = await adminCtx.newPage();
397 try {
398 await page.goto(`${BASE}/delfile-repo/blob/main/index.js`);
399 expect(await page.locator('details:has(summary:text("Delete"))').count()).toBeGreaterThan(0);
400 } finally { await page.close(); }
401 });
402
403 test('Delete button not visible to unauthenticated user', async () => {
404 const ctx = await browser.newContext();
405 const page = await ctx.newPage();
406 try {
407 await page.goto(`${BASE}/delfile-repo/blob/main/index.js`);
408 expect(await page.locator('details:has(summary:text("Delete"))').count()).toBe(0);
409 } finally {
410 await page.close();
411 await ctx.close();
412 }
413 });
414
415 test('deleting a file creates a commit and removes it from the tree', async () => {
416 const resp = await adminCtx.request.post(`${BASE}/delfile-repo/delete-file/main/index.js`, {
417 form: { message: 'Remove index.js' },
418 maxRedirects: 0,
419 });
420 expect(resp.status()).toBe(302);
421 expect(resp.headers()['location']).toMatch(/\/delfile-repo\/commit\/[0-9a-f]{40}/);
422
423 const page = await adminCtx.newPage();
424 try {
425 await page.goto(`${BASE}/delfile-repo/tree/main`);
426 expect(await page.content()).not.toContain('index.js');
427 } finally { await page.close(); }
428 });
429
430 test('delete commit is signed', async () => {
431 const hash = gitOutput(['-C', repoDir('delfile-repo'), 'log', '--format=%H', '--grep=Remove index.js', '-1']);
432 expect(hash).toBeTruthy();
433 const obj = gitOutput(['-C', repoDir('delfile-repo'), 'cat-file', '-p', hash]);
434 expect(obj).toContain('gpgsig');
435 });
436});
437
438// ─── File rename/move ─────────────────────────────────────────────────────────
439
440describe('file rename/move', () => {
441 test('edit form has new_path input pre-filled with current path', async () => {
442 const page = await adminCtx.newPage();
443 try {
444 await page.goto(`${BASE}/movefile-repo/edit/main/index.js`);
445 const newPathInput = await page.locator('[name=new_path]').inputValue();
446 expect(newPathInput).toBe('index.js');
447 } finally { await page.close(); }
448 });
449
450 test('renaming a file via edit creates a commit and old path is gone', async () => {
451 const resp = await adminCtx.request.post(`${BASE}/movefile-repo/edit/main/index.js`, {
452 form: { content: 'console.log("hello");\n', new_path: 'app.js', message: 'Rename index.js to app.js' },
453 maxRedirects: 0,
454 });
455 expect(resp.status()).toBe(302);
456 expect(resp.headers()['location']).toMatch(/\/movefile-repo\/commit\/[0-9a-f]{40}/);
457
458 const page = await adminCtx.newPage();
459 try {
460 await page.goto(`${BASE}/movefile-repo/tree/main`);
461 expect(await page.content()).toContain('app.js');
462 expect(await page.content()).not.toContain('index.js');
463 } finally { await page.close(); }
464 });
465
466 test('rename commit is signed', async () => {
467 const hash = gitOutput(['-C', repoDir('movefile-repo'), 'log', '--format=%H', '--grep=Rename index.js', '-1']);
468 expect(hash).toBeTruthy();
469 const obj = gitOutput(['-C', repoDir('movefile-repo'), 'cat-file', '-p', hash]);
470 expect(obj).toContain('gpgsig');
471 });
472
473 test('renaming to invalid path shows error', async () => {
474 const resp = await adminCtx.request.post(`${BASE}/movefile-repo/edit/main/README.md`, {
475 form: { content: '# movefile-repo\n', new_path: '../escape.md', message: 'bad' },
476 maxRedirects: 0,
477 });
478 expect(resp.status()).toBe(302);
479 expect(resp.headers()['location']).toContain('error');
480 });
481});
482