e2e.navigation.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 seedBranch,
13 seedSubdir,
14} from './helpers.ts';
15
16let browser: Browser;
17let server: Awaited<ReturnType<typeof spawnServer>>;
18
19beforeAll(async () => {
20 await setupTestEnv();
21 server = await spawnServer();
22 browser = await chromium.launch();
23
24 // Create my-repo and seed it
25 const adminCtx = await browser.newContext();
26 const adminPage = await adminCtx.newPage();
27 try {
28 await login(adminPage);
29 await adminPage.goto(`${BASE}/new`);
30 await adminPage.fill('[name=name]', 'my-repo');
31 await adminPage.click('form[action="/new"] button[type=submit]');
32 await adminPage.waitForURL(`${BASE}/my-repo`);
33 } finally { await adminCtx.close(); }
34
35 await seedRepo('my-repo');
36});
37
38afterAll(async () => {
39 await browser.close();
40 await killServer(server);
41});
42
43async function loggedInContext(username = 'admin', password = ADMIN_PASS) {
44 const ctx = await browser.newContext();
45 const page = await ctx.newPage();
46 await login(page, username, password);
47 await page.close();
48 return ctx;
49}
50
51// ─── Branch selector ──────────────────────────────────────────────────────────
52
53describe('branch selector', () => {
54 let adminCtx: BrowserContext;
55
56 beforeAll(async () => {
57 adminCtx = await loggedInContext();
58 // Add a second branch so the selector is meaningful
59 await seedBranch('my-repo', 'dev');
60 });
61
62 afterAll(async () => { await adminCtx.close(); });
63
64 test('branch selector appears on repo home', async () => {
65 const page = await adminCtx.newPage();
66 try {
67 await page.goto(`${BASE}/my-repo`);
68 expect(await page.locator('.branch-selector').isVisible()).toBe(true);
69 expect(await page.locator('.branch-select').inputValue()).toBe('main');
70 } finally { await page.close(); }
71 });
72
73 test('branch selector shows all branches on repo home', async () => {
74 const page = await adminCtx.newPage();
75 try {
76 await page.goto(`${BASE}/my-repo`);
77 const options = await page.locator('.branch-select option').allTextContents();
78 expect(options).toContain('main');
79 expect(options).toContain('dev');
80 } finally { await page.close(); }
81 });
82
83 test('branch selector appears on file tree with current ref selected', async () => {
84 const page = await adminCtx.newPage();
85 try {
86 await page.goto(`${BASE}/my-repo/tree/main`);
87 expect(await page.locator('.branch-selector').isVisible()).toBe(true);
88 expect(await page.locator('.branch-select').inputValue()).toBe('main');
89 } finally { await page.close(); }
90 });
91
92 test('branch selector appears on commit log with current ref selected', async () => {
93 const page = await adminCtx.newPage();
94 try {
95 await page.goto(`${BASE}/my-repo/commits/main`);
96 expect(await page.locator('.branch-selector').isVisible()).toBe(true);
97 expect(await page.locator('.branch-select').inputValue()).toBe('main');
98 } finally { await page.close(); }
99 });
100
101 test('branch selector appears on file blob', async () => {
102 const page = await adminCtx.newPage();
103 try {
104 await page.goto(`${BASE}/my-repo/blob/main/README.md`);
105 expect(await page.locator('.branch-selector').isVisible()).toBe(true);
106 expect(await page.locator('.branch-select').inputValue()).toBe('main');
107 } finally { await page.close(); }
108 });
109
110 test('switching branch on commit log navigates to the selected branch', async () => {
111 const page = await adminCtx.newPage();
112 try {
113 await page.goto(`${BASE}/my-repo/commits/main`);
114 await page.locator('.branch-select').selectOption('dev');
115 await page.locator('form.branch-selector').evaluate((f: any) => f.submit());
116 await page.waitForURL(`${BASE}/my-repo/commits/dev`);
117 expect(page.url()).toContain('/commits/dev');
118 } finally { await page.close(); }
119 });
120
121 test('switching branch on file tree navigates to the selected branch', async () => {
122 const page = await adminCtx.newPage();
123 try {
124 await page.goto(`${BASE}/my-repo/tree/main`);
125 await page.locator('.branch-select').selectOption('dev');
126 await page.locator('form.branch-selector').evaluate((f: any) => f.submit());
127 await page.waitForURL(`${BASE}/my-repo/tree/dev`);
128 expect(page.url()).toContain('/tree/dev');
129 } finally { await page.close(); }
130 });
131
132 test('branch-switch route preserves subpath when switching tree', async () => {
133 const page = await adminCtx.newPage();
134 try {
135 const resp = await page.request.get(
136 `${BASE}/my-repo/branch-switch?view=tree&rev=dev&path=src/foo`,
137 { maxRedirects: 0 },
138 ).catch(r => r);
139 // 302 redirect to /my-repo/tree/dev/src/foo
140 const loc = (resp as any).headers()?.['location'] ?? '';
141 expect(loc).toContain('/tree/dev/src/foo');
142 } finally { await page.close(); }
143 });
144
145 test('branch-switch route redirects commits view correctly', async () => {
146 const page = await adminCtx.newPage();
147 try {
148 const resp = await page.request.get(
149 `${BASE}/my-repo/branch-switch?view=commits&rev=dev`,
150 { maxRedirects: 0 },
151 ).catch(r => r);
152 const loc = (resp as any).headers()?.['location'] ?? '';
153 expect(loc).toContain('/commits/dev');
154 } finally { await page.close(); }
155 });
156
157 test('branch-switch route redirects blob view correctly', async () => {
158 const page = await adminCtx.newPage();
159 try {
160 const resp = await page.request.get(
161 `${BASE}/my-repo/branch-switch?view=blob&rev=dev&path=README.md`,
162 { maxRedirects: 0 },
163 ).catch(r => r);
164 const loc = (resp as any).headers()?.['location'] ?? '';
165 expect(loc).toContain('/blob/dev/README.md');
166 } finally { await page.close(); }
167 });
168});
169
170// ─── Default branch settings ──────────────────────────────────────────────────
171
172describe('default branch settings', () => {
173 // Runs after 'branch selector', so my-repo already has both main and dev branches.
174 let adminCtx: BrowserContext;
175
176 beforeAll(async () => { adminCtx = await loggedInContext(); });
177 afterAll(async () => { await adminCtx.close(); });
178
179 test('settings page shows default branch select', async () => {
180 const page = await adminCtx.newPage();
181 try {
182 await page.goto(`${BASE}/my-repo/settings`);
183 expect(await page.locator('#default_branch').isVisible()).toBe(true);
184 const options = await page.locator('#default_branch option').allTextContents();
185 expect(options).toContain('main');
186 expect(options).toContain('dev');
187 } finally { await page.close(); }
188 });
189
190 test('current default branch is pre-selected', async () => {
191 const page = await adminCtx.newPage();
192 try {
193 await page.goto(`${BASE}/my-repo/settings`);
194 expect(await page.locator('#default_branch').inputValue()).toBe('main');
195 } finally { await page.close(); }
196 });
197
198 test('changing default branch saves and is reflected in the repo home', async () => {
199 const page = await adminCtx.newPage();
200 try {
201 await page.goto(`${BASE}/my-repo/settings`);
202 await page.locator('#default_branch').selectOption('dev');
203 await page.click('form[action$="/settings"] button[type=submit]');
204 expect(await page.locator('.form-success').isVisible()).toBe(true);
205 // The select now shows dev as current
206 expect(await page.locator('#default_branch').inputValue()).toBe('dev');
207
208 // Repo home branch selector should reflect the new default
209 await page.goto(`${BASE}/my-repo`);
210 expect(await page.locator('.branch-select').inputValue()).toBe('dev');
211 } finally { await page.close(); }
212 });
213
214 test('commit log link in repo nav uses the new default branch', async () => {
215 const page = await adminCtx.newPage();
216 try {
217 await page.goto(`${BASE}/my-repo`);
218 const commitsHref = await page.locator('.repo-tab[href*="/commits/"]').getAttribute('href');
219 expect(commitsHref).toContain('/commits/dev');
220 } finally { await page.close(); }
221 });
222
223 test('changing default branch back to main restores original state', async () => {
224 const page = await adminCtx.newPage();
225 try {
226 await page.goto(`${BASE}/my-repo/settings`);
227 await page.locator('#default_branch').selectOption('main');
228 await page.click('form[action$="/settings"] button[type=submit]');
229 expect(await page.locator('.form-success').isVisible()).toBe(true);
230 expect(await page.locator('#default_branch').inputValue()).toBe('main');
231 } finally { await page.close(); }
232 });
233
234 test('settings page shows hint instead of select when repo has no branches', async () => {
235 // Create an empty repo (no commits → no branches)
236 const page = await adminCtx.newPage();
237 try {
238 await page.goto(`${BASE}/new`);
239 await page.fill('[name=name]', 'empty-for-branch-test');
240 await page.click('form[action="/new"] button[type=submit]');
241 await page.waitForURL(`${BASE}/empty-for-branch-test`);
242
243 await page.goto(`${BASE}/empty-for-branch-test/settings`);
244 expect(await page.locator('#default_branch').count()).toBe(0);
245 expect(await page.locator('.form-hint').isVisible()).toBe(true);
246 } finally { await page.close(); }
247 });
248});
249
250// ─── File browser ─────────────────────────────────────────────────────────────
251
252describe('file browser', () => {
253 // my-repo already has README.md + index.js from the file-level beforeAll.
254 // We add a subdirectory here so we can test directory navigation.
255 let adminCtx: BrowserContext;
256
257 beforeAll(async () => {
258 adminCtx = await loggedInContext();
259 await seedSubdir('my-repo', 'src', {
260 'app.ts': 'export {};\n',
261 'README.md': '# src readme\n',
262 });
263 });
264
265 afterAll(async () => { await adminCtx.close(); });
266
267 test('repo home shows file tree instead of recent commits', async () => {
268 const page = await adminCtx.newPage();
269 try {
270 await page.goto(`${BASE}/my-repo`);
271 expect(await page.locator('.file-tree').isVisible()).toBe(true);
272 expect(await page.locator('.repo-commits-section').count()).toBe(0);
273 } finally { await page.close(); }
274 });
275
276 test('repo home file tree lists files and directories', async () => {
277 const page = await adminCtx.newPage();
278 try {
279 await page.goto(`${BASE}/my-repo`);
280 const names = await page.locator('.file-name a').allTextContents();
281 expect(names).toContain('README.md');
282 expect(names).toContain('index.js');
283 expect(names).toContain('src');
284 } finally { await page.close(); }
285 });
286
287 test('directories appear before files in file tree', async () => {
288 const page = await adminCtx.newPage();
289 try {
290 await page.goto(`${BASE}/my-repo`);
291 const names = await page.locator('.file-name a').allTextContents();
292 const srcIdx = names.indexOf('src');
293 const readmeIdx = names.indexOf('README.md');
294 expect(srcIdx).toBeGreaterThanOrEqual(0);
295 expect(readmeIdx).toBeGreaterThanOrEqual(0);
296 expect(srcIdx).toBeLessThan(readmeIdx);
297 } finally { await page.close(); }
298 });
299
300 test('no ".." entry at repository root', async () => {
301 const page = await adminCtx.newPage();
302 try {
303 await page.goto(`${BASE}/my-repo`);
304 const names = await page.locator('.file-name a').allTextContents();
305 expect(names).not.toContain('..');
306 } finally { await page.close(); }
307 });
308
309 test('clicking directory navigates into it', async () => {
310 const page = await adminCtx.newPage();
311 try {
312 await page.goto(`${BASE}/my-repo`);
313 await page.locator('.file-name a', { hasText: 'src' }).click();
314 await page.waitForURL(`${BASE}/my-repo/tree/main/src`);
315 expect(page.url()).toContain('/tree/main/src');
316 } finally { await page.close(); }
317 });
318
319 test('".." entry appears in subdirectory', async () => {
320 const page = await adminCtx.newPage();
321 try {
322 await page.goto(`${BASE}/my-repo/tree/main/src`);
323 const names = await page.locator('.file-name a').allTextContents();
324 expect(names).toContain('..');
325 } finally { await page.close(); }
326 });
327
328 test('".." at one level deep links to tree root', async () => {
329 const page = await adminCtx.newPage();
330 try {
331 await page.goto(`${BASE}/my-repo/tree/main/src`);
332 const upHref = await page.locator('.file-name a', { hasText: '..' }).getAttribute('href');
333 expect(upHref).toBe('/my-repo/tree/main');
334 } finally { await page.close(); }
335 });
336
337 test('files in subdirectory show plain names, not full paths', async () => {
338 const page = await adminCtx.newPage();
339 try {
340 await page.goto(`${BASE}/my-repo/tree/main/src`);
341 const names = await page.locator('.file-name a').allTextContents();
342 expect(names).toContain('app.ts');
343 // Must NOT contain the full path with prefix
344 expect(names).not.toContain('src/app.ts');
345 expect(names).not.toContain('src/README.md');
346 } finally { await page.close(); }
347 });
348
349 test('readme is shown below file tree on repo home', async () => {
350 const page = await adminCtx.newPage();
351 try {
352 await page.goto(`${BASE}/my-repo`);
353 const treeBox = await page.locator('.file-tree').boundingBox();
354 const readmeBox = await page.locator('.readme-section').boundingBox();
355 expect(treeBox).not.toBeNull();
356 expect(readmeBox).not.toBeNull();
357 expect(readmeBox!.y).toBeGreaterThan(treeBox!.y + treeBox!.height - 1);
358 } finally { await page.close(); }
359 });
360
361 test('readme in subdirectory is shown when present', async () => {
362 const page = await adminCtx.newPage();
363 try {
364 await page.goto(`${BASE}/my-repo/tree/main/src`);
365 expect(await page.locator('.readme-section').isVisible()).toBe(true);
366 expect(await page.locator('.readme-section .markdown-body').innerHTML())
367 .toContain('src readme');
368 } finally { await page.close(); }
369 });
370
371 test('file tree on /tree/:ref also shows readme', async () => {
372 const page = await adminCtx.newPage();
373 try {
374 await page.goto(`${BASE}/my-repo/tree/main`);
375 expect(await page.locator('.file-tree').isVisible()).toBe(true);
376 expect(await page.locator('.readme-section').isVisible()).toBe(true);
377 } finally { await page.close(); }
378 });
379});
380