import { afterAll, beforeAll, describe, expect, test } from 'bun:test'; import { chromium } from 'playwright'; import type { Browser, BrowserContext } from 'playwright'; import { ADMIN_PASS, BASE, DATA_DIR, getHeadCommit, gitOutput, killServer, login, seedBranch, seedRepo, setupTestEnv, spawnServer, } from './helpers.ts'; let browser: Browser; let server: Awaited>; let adminCtx: BrowserContext; beforeAll(async () => { await setupTestEnv(); server = await spawnServer(); browser = await chromium.launch(); // Single admin context shared across all describes adminCtx = await browser.newContext(); const page = await adminCtx.newPage(); await login(page, 'admin', ADMIN_PASS); await page.close(); // Create all repos upfront for (const name of ['branch-repo', 'tag-repo', 'selector-repo', 'newfile-repo', 'delfile-repo', 'movefile-repo']) { const p = await adminCtx.newPage(); try { await p.goto(`${BASE}/new`); await p.fill('[name=name]', name); await p.click('form[action="/new"] button[type=submit]'); await p.waitForURL(`${BASE}/${name}`); } finally { await p.close(); } } // Seed repos await seedRepo('branch-repo'); seedBranch('branch-repo', 'feature-a'); await seedRepo('tag-repo'); gitOutput(['-C', repoDir('tag-repo'), 'tag', 'v0.1.0', 'HEAD']); await seedRepo('selector-repo'); gitOutput(['-C', repoDir('selector-repo'), 'tag', 'stable', 'HEAD']); await seedRepo('newfile-repo'); await seedRepo('delfile-repo'); await seedRepo('movefile-repo'); }); afterAll(async () => { await adminCtx.close(); await browser.close(); await killServer(server); }); function repoDir(name: string) { return `${process.cwd()}/${DATA_DIR}/repos/${name}.git`; } // ─── Branch management ──────────────────────────────────────────────────────── describe('branches', () => { test('Branches tab appears in repo nav', async () => { const page = await adminCtx.newPage(); try { await page.goto(`${BASE}/branch-repo`); const tab = page.locator('a.repo-tab', { hasText: 'Branches' }); expect(await tab.isVisible()).toBe(true); } finally { await page.close(); } }); test('branches page lists all branches', async () => { const page = await adminCtx.newPage(); try { await page.goto(`${BASE}/branch-repo/branches`); const content = await page.content(); expect(content).toContain('main'); expect(content).toContain('feature-a'); } finally { await page.close(); } }); test('default branch has a "default" badge', async () => { const page = await adminCtx.newPage(); try { await page.goto(`${BASE}/branch-repo/branches`); expect(await page.locator('.badge', { hasText: 'default' }).count()).toBeGreaterThan(0); } finally { await page.close(); } }); test('branch list shows last commit hash and subject', async () => { const sha = getHeadCommit('branch-repo').slice(0, 7); const page = await adminCtx.newPage(); try { await page.goto(`${BASE}/branch-repo/branches`); const content = await page.content(); expect(content).toContain(sha); expect(content).toContain('Initial commit'); } finally { await page.close(); } }); test('branches page returns 404 for non-existent repo', async () => { const ctx = await browser.newContext(); const page = await ctx.newPage(); try { const resp = await page.request.get(`${BASE}/does-not-exist/branches`); expect(resp.status()).toBe(404); } finally { await page.close(); await ctx.close(); } }); test('create a new branch', async () => { const page = await adminCtx.newPage(); try { await page.goto(`${BASE}/branch-repo/branches`); await page.click('details:has(summary:text("New branch")) summary'); await page.fill('[name=name]', 'new-feature'); await page.fill('[name=source_ref]', 'main'); await page.click('form[action*="branches/create"] button[type=submit]'); await page.waitForURL(/branches/); expect(await page.locator('.ref-item').filter({ hasText: 'new-feature' }).count()).toBeGreaterThan(0); } finally { await page.close(); } }); test('creating branch with invalid name shows error', async () => { const resp = await adminCtx.request.post(`${BASE}/branch-repo/branches/create`, { form: { name: '--invalid', source_ref: 'main' }, }); expect(resp.url()).toContain('error'); }); test('creating branch from non-existent ref shows error', async () => { const resp = await adminCtx.request.post(`${BASE}/branch-repo/branches/create`, { form: { name: 'bad-branch', source_ref: 'does-not-exist' }, }); expect(resp.url()).toContain('error'); }); test('rename a branch', async () => { const page = await adminCtx.newPage(); try { await page.goto(`${BASE}/branch-repo/branches`); const row = page.locator('.ref-item').filter({ hasText: 'feature-a' }); await row.locator('details:has(summary:text("Rename")) summary').click(); await row.locator('[name=new_name]').fill('feature-renamed'); await row.locator('form[action*="branches/rename"] button[type=submit]').click(); await page.waitForURL(/branches/); expect(await page.locator('.ref-item').filter({ hasText: 'feature-renamed' }).count()).toBeGreaterThan(0); expect(await page.locator('.ref-item').filter({ hasText: 'feature-a' }).count()).toBe(0); } finally { await page.close(); } }); test('delete a non-default branch', async () => { const page = await adminCtx.newPage(); try { await page.goto(`${BASE}/branch-repo/branches`); const row = page.locator('.ref-item').filter({ hasText: 'new-feature' }); await row.locator('details:has(summary:text("Delete")) summary').click(); await row.locator('form[action*="branches/delete"] button[type=submit]').click(); await page.waitForURL(/branches/); expect(await page.locator('.ref-item').filter({ hasText: 'new-feature' }).count()).toBe(0); } finally { await page.close(); } }); test('cannot delete the default branch (no Delete button on main row)', async () => { const page = await adminCtx.newPage(); try { await page.goto(`${BASE}/branch-repo/branches`); const mainRow = page.locator('.ref-item').filter({ hasText: 'main' }); expect(await mainRow.locator('summary:text("Delete")').count()).toBe(0); } finally { await page.close(); } }); test('renaming default branch updates it in repo', async () => { const page = await adminCtx.newPage(); try { await page.goto(`${BASE}/branch-repo/branches`); const row = page.locator('.ref-item').filter({ hasText: 'main' }); await row.locator('details:has(summary:text("Rename")) summary').click(); await row.locator('[name=new_name]').fill('trunk'); await row.locator('form[action*="branches/rename"] button[type=submit]').click(); await page.waitForURL(/branches/); expect(await page.locator('.ref-item').filter({ hasText: 'trunk' }).locator('.badge', { hasText: 'default' }).count()).toBeGreaterThan(0); // Rename back await page.goto(`${BASE}/branch-repo/branches`); const trunkRow = page.locator('.ref-item').filter({ hasText: 'trunk' }); await trunkRow.locator('details:has(summary:text("Rename")) summary').click(); await trunkRow.locator('[name=new_name]').fill('main'); await trunkRow.locator('form[action*="branches/rename"] button[type=submit]').click(); await page.waitForURL(/branches/); } finally { await page.close(); } }); }); // ─── Tag management ─────────────────────────────────────────────────────────── describe('tags', () => { test('Tags tab appears in repo nav', async () => { const page = await adminCtx.newPage(); try { await page.goto(`${BASE}/tag-repo`); const tab = page.locator('a.repo-tab', { hasText: 'Tags' }); expect(await tab.isVisible()).toBe(true); } finally { await page.close(); } }); test('tags page lists existing tags', async () => { const page = await adminCtx.newPage(); try { await page.goto(`${BASE}/tag-repo/tags`); expect(await page.content()).toContain('v0.1.0'); } finally { await page.close(); } }); test('tag links to correct tree view', async () => { const page = await adminCtx.newPage(); try { await page.goto(`${BASE}/tag-repo/tags`); const link = page.locator(`a[href="/${['tag-repo', 'tree', 'v0.1.0'].join('/')}"]`); expect(await link.count()).toBeGreaterThan(0); } finally { await page.close(); } }); test('create a new tag', async () => { const page = await adminCtx.newPage(); try { await page.goto(`${BASE}/tag-repo/tags`); await page.click('details:has(summary:text("New tag")) summary'); await page.fill('[name=name]', 'v1.0.0'); await page.click('form[action*="tags/create"] button[type=submit]'); await page.waitForURL(/tags/); expect(await page.locator('.ref-item').filter({ hasText: 'v1.0.0' }).count()).toBeGreaterThan(0); } finally { await page.close(); } }); test('create annotated tag with message', async () => { const page = await adminCtx.newPage(); try { await page.goto(`${BASE}/tag-repo/tags`); await page.click('details:has(summary:text("New tag")) summary'); await page.fill('[name=name]', 'v1.1.0-annotated'); await page.fill('[name=message]', 'Annotated release tag'); await page.click('form[action*="tags/create"] button[type=submit]'); await page.waitForURL(/tags/); expect(await page.locator('.ref-item').filter({ hasText: 'v1.1.0-annotated' }).count()).toBeGreaterThan(0); } finally { await page.close(); } }); test('delete a tag', async () => { const page = await adminCtx.newPage(); try { await page.goto(`${BASE}/tag-repo/tags`); const row = page.locator('.ref-item').filter({ hasText: 'v1.0.0' }); await row.locator('details:has(summary:text("Delete")) summary').click(); await row.locator('form[action*="tags/delete"] button[type=submit]').click(); await page.waitForURL(/tags/); expect(await page.locator('.ref-item').filter({ hasText: 'v1.0.0' }).count()).toBe(0); expect(await page.locator('.ref-item').filter({ hasText: 'v0.1.0' }).count()).toBeGreaterThan(0); } finally { await page.close(); } }); test('tag linked to release shows release badge and warning on delete', async () => { const resp = await adminCtx.request.post(`${BASE}/tag-repo/releases`, { multipart: { name: 'Linked Release', create_tag: 'on', tag_name: 'v-linked', revision: 'main' }, maxRedirects: 0, }); expect(resp.status()).toBe(302); const page = await adminCtx.newPage(); try { await page.goto(`${BASE}/tag-repo/tags`); const row = page.locator('.ref-item').filter({ hasText: 'v-linked' }); expect(await row.locator('.badge-release').count()).toBeGreaterThan(0); await row.locator('details:has(summary:text("Delete")) summary').click(); expect(await row.locator('.confirm-warning').count()).toBeGreaterThan(0); } finally { await page.close(); } }); }); // ─── BranchSelector with tags ───────────────────────────────────────────────── describe('BranchSelector tags integration', () => { test('branch selector shows Tags optgroup when tags exist', async () => { const page = await adminCtx.newPage(); try { await page.goto(`${BASE}/selector-repo`); expect(await page.locator('optgroup[label="Tags"]').count()).toBe(1); expect(await page.locator('option', { hasText: 'stable' }).count()).toBeGreaterThan(0); } finally { await page.close(); } }); test('branch selector shows Branches optgroup when tags exist', async () => { const page = await adminCtx.newPage(); try { await page.goto(`${BASE}/selector-repo`); expect(await page.locator('optgroup[label="Branches"]').count()).toBe(1); } finally { await page.close(); } }); test('branch selector on blob view includes tag optgroup', async () => { const page = await adminCtx.newPage(); try { await page.goto(`${BASE}/selector-repo/blob/main/README.md`); expect(await page.locator('optgroup[label="Tags"]').count()).toBe(1); } finally { await page.close(); } }); }); // ─── File creation ──────────────────────────────────────────────────────────── describe('file creation', () => { test('New file button appears in tree toolbar for admin on a branch', async () => { const page = await adminCtx.newPage(); try { await page.goto(`${BASE}/newfile-repo/tree/main`); expect(await page.locator('a[href*="/new-file/main"]').count()).toBeGreaterThan(0); } finally { await page.close(); } }); test('New file button not visible on commit SHA view', async () => { const sha = getHeadCommit('newfile-repo'); const page = await adminCtx.newPage(); try { await page.goto(`${BASE}/newfile-repo/tree/${sha}`); expect(await page.locator('a[href*="/new-file/"]').count()).toBe(0); } finally { await page.close(); } }); test('New file button not visible to unauthenticated user', async () => { const ctx = await browser.newContext(); const page = await ctx.newPage(); try { await page.goto(`${BASE}/newfile-repo/tree/main`); expect(await page.locator('a[href*="/new-file/main"]').count()).toBe(0); } finally { await page.close(); await ctx.close(); } }); test('new file form pre-fills dir when ?dir= query param is provided', async () => { const page = await adminCtx.newPage(); try { await page.goto(`${BASE}/newfile-repo/new-file/main?dir=src`); const pathInput = await page.locator('[name=path]').inputValue(); expect(pathInput).toBe('src/'); } finally { await page.close(); } }); test('creating a new file creates a commit and redirects to commit view', async () => { const resp = await adminCtx.request.post(`${BASE}/newfile-repo/new-file/main`, { form: { path: 'hello.txt', content: 'Hello, world!\n', message: 'Add hello.txt' }, maxRedirects: 0, }); expect(resp.status()).toBe(302); expect(resp.headers()['location']).toMatch(/\/newfile-repo\/commit\/[0-9a-f]{40}/); }); test('new file appears in tree after creation', async () => { const page = await adminCtx.newPage(); try { await page.goto(`${BASE}/newfile-repo/tree/main`); expect(await page.content()).toContain('hello.txt'); } finally { await page.close(); } }); test('new file commit is signed', async () => { const hash = gitOutput(['-C', repoDir('newfile-repo'), 'log', '--format=%H', '--grep=Add hello.txt', '-1']); expect(hash).toBeTruthy(); const obj = gitOutput(['-C', repoDir('newfile-repo'), 'cat-file', '-p', hash]); expect(obj).toContain('gpgsig'); }); test('creating file with invalid path shows error', async () => { const resp = await adminCtx.request.post(`${BASE}/newfile-repo/new-file/main`, { form: { path: '../escape', content: '', message: 'bad' }, }); expect(resp.url()).toContain('error'); }); }); // ─── File deletion ──────────────────────────────────────────────────────────── describe('file deletion', () => { test('Delete button appears in file blob for admin on a branch', async () => { const page = await adminCtx.newPage(); try { await page.goto(`${BASE}/delfile-repo/blob/main/index.js`); expect(await page.locator('details:has(summary:text("Delete"))').count()).toBeGreaterThan(0); } finally { await page.close(); } }); test('Delete button not visible to unauthenticated user', async () => { const ctx = await browser.newContext(); const page = await ctx.newPage(); try { await page.goto(`${BASE}/delfile-repo/blob/main/index.js`); expect(await page.locator('details:has(summary:text("Delete"))').count()).toBe(0); } finally { await page.close(); await ctx.close(); } }); test('deleting a file creates a commit and removes it from the tree', async () => { const resp = await adminCtx.request.post(`${BASE}/delfile-repo/delete-file/main/index.js`, { form: { message: 'Remove index.js' }, maxRedirects: 0, }); expect(resp.status()).toBe(302); expect(resp.headers()['location']).toMatch(/\/delfile-repo\/commit\/[0-9a-f]{40}/); const page = await adminCtx.newPage(); try { await page.goto(`${BASE}/delfile-repo/tree/main`); expect(await page.content()).not.toContain('index.js'); } finally { await page.close(); } }); test('delete commit is signed', async () => { const hash = gitOutput(['-C', repoDir('delfile-repo'), 'log', '--format=%H', '--grep=Remove index.js', '-1']); expect(hash).toBeTruthy(); const obj = gitOutput(['-C', repoDir('delfile-repo'), 'cat-file', '-p', hash]); expect(obj).toContain('gpgsig'); }); }); // ─── File rename/move ───────────────────────────────────────────────────────── describe('file rename/move', () => { test('edit form has new_path input pre-filled with current path', async () => { const page = await adminCtx.newPage(); try { await page.goto(`${BASE}/movefile-repo/edit/main/index.js`); const newPathInput = await page.locator('[name=new_path]').inputValue(); expect(newPathInput).toBe('index.js'); } finally { await page.close(); } }); test('renaming a file via edit creates a commit and old path is gone', async () => { const resp = await adminCtx.request.post(`${BASE}/movefile-repo/edit/main/index.js`, { form: { content: 'console.log("hello");\n', new_path: 'app.js', message: 'Rename index.js to app.js' }, maxRedirects: 0, }); expect(resp.status()).toBe(302); expect(resp.headers()['location']).toMatch(/\/movefile-repo\/commit\/[0-9a-f]{40}/); const page = await adminCtx.newPage(); try { await page.goto(`${BASE}/movefile-repo/tree/main`); expect(await page.content()).toContain('app.js'); expect(await page.content()).not.toContain('index.js'); } finally { await page.close(); } }); test('rename commit is signed', async () => { const hash = gitOutput(['-C', repoDir('movefile-repo'), 'log', '--format=%H', '--grep=Rename index.js', '-1']); expect(hash).toBeTruthy(); const obj = gitOutput(['-C', repoDir('movefile-repo'), 'cat-file', '-p', hash]); expect(obj).toContain('gpgsig'); }); test('renaming to invalid path shows error', async () => { const resp = await adminCtx.request.post(`${BASE}/movefile-repo/edit/main/README.md`, { form: { content: '# movefile-repo\n', new_path: '../escape.md', message: 'bad' }, maxRedirects: 0, }); expect(resp.status()).toBe(302); expect(resp.headers()['location']).toContain('error'); }); });