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