e2e.repos.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 ctx = await browser.newContext();
24 const page = await ctx.newPage();
25 try {
26 await page.goto(`${BASE}/register`);
27 await page.fill('[name=username]', 'alice');
28 await page.fill('[name=password]', 'password123');
29 await page.fill('[name=password2]', 'password123');
30 await page.click('button[type=submit]');
31 await page.waitForURL(BASE + '/');
32 } finally { await ctx.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// ─── Repos ────────────────────────────────────────────────────────────────────
49
50describe('repos', () => {
51 // Shared admin context — cookies persist across tests in this block.
52 let adminCtx: BrowserContext;
53 // Alice's context, created after alice is registered in auth tests.
54 let aliceCtx: BrowserContext;
55 // Set after 'commit log' test; used by all commit-detail tests below.
56 let commitUrl: string;
57
58 beforeAll(async () => {
59 adminCtx = await loggedInContext();
60 aliceCtx = await loggedInContext('alice', 'password123');
61 });
62
63 afterAll(async () => {
64 await adminCtx.close();
65 await aliceCtx.close();
66 });
67
68 test('non-admin gets 403 on /new', async () => {
69 const page = await aliceCtx.newPage();
70 try {
71 const resp = await page.request.get(`${BASE}/new`);
72 expect(resp.status()).toBe(403);
73 } finally { await page.close(); }
74 });
75
76 test('create repository', async () => {
77 const page = await adminCtx.newPage();
78 try {
79 await page.goto(`${BASE}/new`);
80 await page.fill('[name=name]', 'my-repo');
81 await page.fill('[name=description]', 'A test repo');
82 await page.click('form[action="/new"] button[type=submit]');
83 await page.waitForURL(`${BASE}/my-repo`);
84 expect(await page.locator('.empty-state').isVisible()).toBe(true);
85 } finally { await page.close(); }
86 });
87
88 test('repository appears in list', async () => {
89 const page = await adminCtx.newPage();
90 try {
91 await page.goto(BASE);
92 expect(await page.locator('.repo-name').allTextContents()).toContain('my-repo');
93 } finally { await page.close(); }
94 });
95
96 test('search finds matching repo', async () => {
97 const page = await adminCtx.newPage();
98 try {
99 await page.goto(BASE);
100 await page.fill('[name=q]', 'my-repo');
101 await page.click('.search-form button[type=submit]');
102 expect(await page.locator('.repo-name').allTextContents()).toContain('my-repo');
103 } finally { await page.close(); }
104 });
105
106 test('search returns empty for unknown term', async () => {
107 const page = await adminCtx.newPage();
108 try {
109 await page.goto(BASE);
110 await page.fill('[name=q]', 'zzz-nothing-here');
111 await page.click('.search-form button[type=submit]');
112 expect(await page.locator('.empty-state').isVisible()).toBe(true);
113 } finally { await page.close(); }
114 });
115
116 test('browse file tree after seeding content', async () => {
117 await seedRepo('my-repo');
118 const page = await adminCtx.newPage();
119 try {
120 await page.goto(`${BASE}/my-repo/tree/main`);
121 const files = await page.locator('.file-name a').allTextContents();
122 expect(files).toContain('README.md');
123 expect(files).toContain('index.js');
124 } finally { await page.close(); }
125 });
126
127 test('view file blob with syntax highlighting', async () => {
128 const page = await adminCtx.newPage();
129 try {
130 await page.goto(`${BASE}/my-repo/blob/main/index.js`);
131 expect(await page.locator('.file-blob-name').textContent()).toBe('index.js');
132 expect(await page.locator('.file-blob-body').isVisible()).toBe(true);
133 } finally { await page.close(); }
134 });
135
136 test('raw file download responds 200', async () => {
137 const page = await adminCtx.newPage();
138 try {
139 const resp = await page.request.get(`${BASE}/my-repo/raw/main/README.md`);
140 expect(resp.status()).toBe(200);
141 expect(resp.headers()['content-disposition']).toContain('README.md');
142 } finally { await page.close(); }
143 });
144
145 test('commit log shows initial commit', async () => {
146 const page = await adminCtx.newPage();
147 try {
148 await page.goto(`${BASE}/my-repo/commits/main`);
149 const subjects = await page.locator('.commit-subject').allTextContents();
150 expect(subjects.some(s => s.includes('Initial commit'))).toBe(true);
151 // Navigate to the commit page and capture the URL for subsequent tests
152 await page.locator('.commit-hash').first().click();
153 await page.waitForURL(/\/my-repo\/commit\//);
154 commitUrl = page.url();
155 } finally { await page.close(); }
156 });
157
158 test('commit detail shows metadata card', async () => {
159 const page = await adminCtx.newPage();
160 try {
161 await page.goto(commitUrl);
162 expect(await page.locator('.commit-card').isVisible()).toBe(true);
163 expect(await page.locator('.commit-card-subject').textContent()).toContain('Initial commit');
164 // Author, date and SHA rows are all present
165 const metaText = await page.locator('.commit-card-meta').textContent();
166 expect(metaText).toContain('Test'); // author name set by seedRepo
167 expect(metaText).toContain('Author'); // label (CSS uppercases visually)
168 expect(metaText).toContain('Date');
169 expect(metaText).toContain('Commit');
170 } finally { await page.close(); }
171 });
172
173 test('commit detail full SHA is shown', async () => {
174 const page = await adminCtx.newPage();
175 try {
176 await page.goto(commitUrl);
177 const sha = commitUrl.split('/commit/')[1] ?? null;
178 expect(await page.locator('.commit-sha-full').textContent()).toBe(sha);
179 } finally { await page.close(); }
180 });
181
182 test('commit detail shows file nav sidebar', async () => {
183 const page = await adminCtx.newPage();
184 try {
185 await page.goto(commitUrl);
186 expect(await page.locator('.file-nav-details').isVisible()).toBe(true);
187 const navItems = await page.locator('.file-nav-item').allTextContents();
188 // seedRepo adds README.md and index.js
189 expect(navItems.some(t => t.includes('README.md'))).toBe(true);
190 expect(navItems.some(t => t.includes('index.js'))).toBe(true);
191 } finally { await page.close(); }
192 });
193
194 test('commit detail file nav items are anchor links to diff sections', async () => {
195 const page = await adminCtx.newPage();
196 try {
197 await page.goto(commitUrl);
198 const hrefs = await page.locator('.file-nav-item').evaluateAll(
199 els => els.map(el => el.getAttribute('href') ?? ''),
200 );
201 expect(hrefs.every(h => h.startsWith('#'))).toBe(true);
202 } finally { await page.close(); }
203 });
204
205 test('commit detail shows diff table with added lines', async () => {
206 const page = await adminCtx.newPage();
207 try {
208 await page.goto(commitUrl);
209 // Initial commit only adds lines
210 expect(await page.locator('.diff-table').first().isVisible()).toBe(true);
211 expect(await page.locator('.diff-row-add').count()).toBeGreaterThan(0);
212 expect(await page.locator('.diff-row-del').count()).toBe(0);
213 } finally { await page.close(); }
214 });
215
216 test('commit detail diff table has line numbers', async () => {
217 const page = await adminCtx.newPage();
218 try {
219 await page.goto(commitUrl);
220 // New-side line numbers (column 2) on add rows start at 1
221 const firstNewLn = await page.locator('.diff-row-add .diff-ln-new').first().textContent();
222 expect(firstNewLn?.trim()).toBe('1');
223 } finally { await page.close(); }
224 });
225
226 test('commit detail shows added stats on file header', async () => {
227 const page = await adminCtx.newPage();
228 try {
229 await page.goto(commitUrl);
230 const addStats = await page.locator('.diff-stat-add').allTextContents();
231 expect(addStats.length).toBeGreaterThan(0);
232 expect(addStats.every(s => s.startsWith('+'))).toBe(true);
233 } finally { await page.close(); }
234 });
235
236 test('commit detail view-at-sha button links to blob at that commit', async () => {
237 const page = await adminCtx.newPage();
238 try {
239 await page.goto(commitUrl);
240 const sha = commitUrl.split('/commit/')[1];
241 const btn = page.locator('.btn-xs').first();
242 const href = await btn.getAttribute('href');
243 expect(href).toContain(`/blob/${sha}/`);
244 } finally { await page.close(); }
245 });
246
247 test('commit detail view-at-branch button links to blob at default branch', async () => {
248 const page = await adminCtx.newPage();
249 try {
250 await page.goto(commitUrl);
251 const btns = await page.locator('.btn-xs').allTextContents();
252 expect(btns.some(t => t.includes('@ main'))).toBe(true);
253 const branchBtns = await page.locator('.btn-xs').evaluateAll(
254 els => els.filter(el => el.textContent?.includes('@ main')).map(el => el.getAttribute('href') ?? ''),
255 );
256 expect(branchBtns.every(h => h.includes('/blob/main/'))).toBe(true);
257 } finally { await page.close(); }
258 });
259
260 test('commit detail file diff can be collapsed', async () => {
261 const page = await adminCtx.newPage();
262 try {
263 await page.goto(commitUrl);
264 // File body is visible when details is open
265 const diffFile = page.locator('.diff-file').first();
266 expect(await diffFile.getAttribute('open')).not.toBeNull();
267 // Click the summary to collapse
268 await diffFile.locator('.diff-file-header').click();
269 expect(await diffFile.getAttribute('open')).toBeNull();
270 } finally { await page.close(); }
271 });
272
273 test('commit detail file nav sidebar can be collapsed', async () => {
274 const page = await adminCtx.newPage();
275 try {
276 await page.goto(commitUrl);
277 const nav = page.locator('.file-nav-details');
278 expect(await nav.getAttribute('open')).not.toBeNull();
279 await nav.locator('.file-nav-toggle').click();
280 expect(await nav.getAttribute('open')).toBeNull();
281 } finally { await page.close(); }
282 });
283
284 test('readme renders on repo home', async () => {
285 const page = await adminCtx.newPage();
286 try {
287 await page.goto(`${BASE}/my-repo`);
288 expect(await page.locator('.readme-header').isVisible()).toBe(true);
289 expect(await page.locator('.readme-section .markdown-body').innerHTML()).toContain('my-repo');
290 } finally { await page.close(); }
291 });
292
293 test('private repo hidden from other users', async () => {
294 // Make private
295 const adminPage = await adminCtx.newPage();
296 try {
297 await adminPage.goto(`${BASE}/my-repo/settings`);
298 await adminPage.check('[name=is_private]');
299 await adminPage.click('form[action$="/settings"] button[type=submit]');
300 expect(await adminPage.locator('.form-success').isVisible()).toBe(true);
301 } finally { await adminPage.close(); }
302
303 // Alice should get 404
304 const alicePage = await aliceCtx.newPage();
305 try {
306 const resp = await alicePage.request.get(`${BASE}/my-repo`);
307 expect(resp.status()).toBe(404);
308 await alicePage.goto(BASE);
309 expect(await alicePage.locator('.repo-name').allTextContents()).not.toContain('my-repo');
310 } finally { await alicePage.close(); }
311
312 // Restore to public
313 const adminPage2 = await adminCtx.newPage();
314 try {
315 await adminPage2.goto(`${BASE}/my-repo/settings`);
316 await adminPage2.uncheck('[name=is_private]');
317 await adminPage2.click('form[action$="/settings"] button[type=submit]');
318 } finally { await adminPage2.close(); }
319 });
320
321 test('settings tab visible for admin, hidden for others', async () => {
322 const adminPage = await adminCtx.newPage();
323 try {
324 await adminPage.goto(`${BASE}/my-repo`);
325 expect(await adminPage.locator('.repo-tab[href$="/settings"]').isVisible()).toBe(true);
326 } finally { await adminPage.close(); }
327
328 const alicePage = await aliceCtx.newPage();
329 try {
330 await alicePage.goto(`${BASE}/my-repo`);
331 expect(await alicePage.locator('.repo-tab[href$="/settings"]').count()).toBe(0);
332 } finally { await alicePage.close(); }
333 });
334
335 test('create repository with invalid name shows error', async () => {
336 const resp = await adminCtx.request.post(`${BASE}/new`, {
337 form: { name: 'has spaces!', description: '', default_branch: 'main' },
338 maxRedirects: 0,
339 });
340 expect(resp.status()).toBe(200);
341 expect(await resp.text()).toContain('Invalid repository name');
342 });
343});
344